Blog ·
CVE-2026-91768: FastCGI allowed_clients compared only 96 of an IPv6 address's 128 bits
php-fpm accepted FastCGI connections from any address sharing the first 96 bits of an allowed IPv6 address, because one memcmp asked for 12 bytes instead of 16.
- CVE
- CVE-2026-91768
- Severity
- medium
- Affected
PHP < 8.2.34, 8.3.x < 8.3.35, 8.4.x < 8.4.26, 8.5.x < 8.5.11
A php-fpm pool with listen.allowed_clients = ::1 accepted FastCGI connections from every address in ::/96. That is 4,294,967,296 addresses where you asked for one. Every release from PHP 5.5.20 up to the ones shipping on 24 September 2026 did this on any pool listening over IPv6.
Where the allow list gets checked
php-fpm has an allow list for FastCGI clients. You write it in the pool config as listen.allowed_clients, a comma separated list of IP addresses, and php-fpm matches every accepted connection against it before a worker reads a byte of the request. The standalone FastCGI SAPI has the same feature under the FCGI_WEB_SERVER_ADDRS environment variable. Both parse their list with inet_pton() into the same array, and both go through one function in main/fastcgi.c.
static int fcgi_is_allowed(void) {
int i;
if (client_sa.sa.sa_family == AF_UNIX) {
return 1;
}
if (!allowed_clients) {
return 1;
}
if (client_sa.sa.sa_family == AF_INET) {
for (i = 0; allowed_clients[i].sa.sa_family ; i++) {
if (allowed_clients[i].sa.sa_family == AF_INET
&& !memcmp(&client_sa.sa_inet.sin_addr, &allowed_clients[i].sa_inet.sin_addr, 4)) {
return 1;
}
}
}
#ifdef HAVE_IPV6
if (client_sa.sa.sa_family == AF_INET6) {
for (i = 0; allowed_clients[i].sa.sa_family ; i++) {
if (allowed_clients[i].sa.sa_family == AF_INET6
&& !memcmp(&client_sa.sa_inet6.sin6_addr, &allowed_clients[i].sa_inet6.sin6_addr, 12)) { // <- 12 of 16 bytes
return 1;
}
#ifdef IN6_IS_ADDR_V4MAPPED
if (allowed_clients[i].sa.sa_family == AF_INET
&& IN6_IS_ADDR_V4MAPPED(&client_sa.sa_inet6.sin6_addr)
&& !memcmp(((char *)&client_sa.sa_inet6.sin6_addr)+12, &allowed_clients[i].sa_inet.sin_addr, 4)) {
return 1;
}
#endif
}
}
#endif
return 0;
}memcmp returns zero when the two buffers are equal over the requested length, so !memcmp(...) reads as “these addresses match”. The IPv4 branch asks for 4 bytes, which is the whole of an IPv4 address, and the IPv6 branch asks for 12 when an IPv6 address is 16, so the comparison never read the low 32 bits of the client address.
The twelve that should have been a sixteen
The 12 on the flawed line is the 12 from the branch just below it, where it means an offset instead of a length. That second memcmp handles IPv4 mapped addresses, and when a dual stack socket accepts an IPv4 client the kernel presents the peer as ::ffff:a.b.c.d, so matching it against an IPv4 entry in the allow list means skipping the 12 byte ::ffff: prefix and comparing the 4 bytes after it. There 12 is a pointer offset and 4 is the length, and it is right. One branch up, 12 sits in the length argument and turns an exact match into a /96 prefix match.
Rémi Collet added IPv6 support to this allow list on 17 November 2014 in commit 3a8103a, closing bug #68428, titled “allowed_client is IPv4 only”. He wrote the whole function in that one commit, both comparisons at once, it shipped in PHP 5.5.20 and 5.6.4, and nobody touched the comparison for the next eleven years.
So why did nobody notice? sapi/fpm/tests/ had no coverage of listen.allowed_clients over IPv6, and a working configuration looks identical whether the match is exact or /96. The only way to see the bug is to connect from an address you expect to be refused, and you do not probe a deployment that already serves traffic.
::/96 is not exotic space, since it contains ::1, the value most people put in that field, plus :: and every ::a.b.c.d. And if you wrote listen.allowed_clients = ::ffff:127.0.0.1, inet_pton stores it as an IPv6 entry whose first 12 bytes are exactly the IPv4 mapped prefix, so every IPv4 client arriving over a dual stack listener matched it, which meant one allowed IPv4 address was all of them.
What I changed
if (client_sa.sa.sa_family == AF_INET6) {
for (i = 0; allowed_clients[i].sa.sa_family ; i++) {
if (allowed_clients[i].sa.sa_family == AF_INET6
- && !memcmp(&client_sa.sa_inet6.sin6_addr, &allowed_clients[i].sa_inet6.sin6_addr, 12)) {
+ && !memcmp(&client_sa.sa_inet6.sin6_addr, &allowed_clients[i].sa_inet6.sin6_addr, sizeof(client_sa.sa_inet6.sin6_addr))) {
return 1;
}That is the whole change to the C, and sizeof(client_sa.sa_inet6.sin6_addr) is 16, so writing it that way means the length can no longer drift away from the type it describes and the comparison now means what the configuration file says it means.
I put it on PHP-8.2 first and merged it up through 8.3, 8.4 and 8.5, the four branches still receiving security fixes, so anything on 8.1 or older is affected and will not get a patch. I added two .phpt tests, one for plain IPv6 and one for the IPv4 mapped case, and each starts two pools, one whose allow list should match the client and one whose allow list should not, then asserts that php-fpm drops the second connection and logs the Connection disallowed warning. Each one pins the refusal the old code let through.
The patch adds no CIDR support, and listen.allowed_clients has never understood it, so if a pool of yours was leaning on the accidental /96 to admit a range, that range went away on 24 September and no configuration key replaces it. Ranges belong in your firewall or your reverse proxy, and php-fpm will not express them for you.
Other languages, same twelve
You hit this bug in IPv6 code because 12 really is the right number in one of the two cases a correct implementation has to handle. Go’s standard library covers both cases in one function without conflating them:
func (ip IP) Equal(x IP) bool {
if len(ip) == len(x) {
return bytealg.Equal(ip, x)
}
if len(ip) == IPv4len && len(x) == IPv6len {
return bytealg.Equal(x[0:12], v4InV6Prefix) && bytealg.Equal(ip, x[12:])
}
// ...
}The Go version dodges the trap twice, because the full comparison derives its length from len(ip) instead of a literal and so cannot come up short, and 12 appears only as a slice bound in the branch about the mapped prefix, right next to v4InV6Prefix, a named constant that is itself 12 bytes long.
A bare integer in a comparison should make you ask which type it came from, and the answer is sizeof or len. When two near identical comparisons sit next to each other and one is the special case, check the special case for the constant the other one borrowed.
Timeline
- Introduced: 17 November 2014, commit
3a8103aby Rémi Collet, released in PHP 5.5.20 and 5.6.4 - Reported: advisory opened 7 July 2026, credited to
l8BL,OSTIF-Derekandiliaal - Analysed:
iluuu1994and myself - Fixed: 25 August 2026, commit
dcdfcf8, reviewed byarnaud-lbandbukka - Published: 24 September 2026
- Patched versions: 8.2.34, 8.3.35, 8.4.26, 8.5.11
CVSS v3.1 is AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N, scored 6.5, and the advisory classes it as CWE-187, partial comparison.
Eleven years is a long life for four unread bytes, and the line was never wrong in any way a configuration file could reveal, so the only thing that would have caught it is a test that connects from an address it expects to be refused. That is the test I would write first next time.
Sources
- https://github.com/php/php-src/security/advisories/GHSA-62xp-839h-2637
- https://github.com/php/php-src/commit/dcdfcf86fcf7f42fa98dd3d000f310ebdad41965
- https://github.com/php/php-src/commit/3a8103ae4738824ebb27a9a739e253740580ed36
- https://bugs.php.net/bug.php?id=68428
- https://www.php.net/supported-versions.php
- https://github.com/golang/go/blob/master/src/net/ip.go