ext/curl: add socket callback options bridging to ext/sockets - #22159
ext/curl: add socket callback options bridging to ext/sockets#22159xavierleune wants to merge 1 commit into
Conversation
e34e404 to
3ed7c46
Compare
|
@Girgias 👋 hi gina, do you mind having a look on this one ? |
Girgias
left a comment
There was a problem hiding this comment.
This looks sensible, although this probably would only ever work if ext/socket (and possibly ext/curl) are built statically into PHP. Which might be a problem for distributions.
I'm not really an expert in how to determine and handle optional dependencies, especially if they can be shared objects. So maybe @devnexen or @remicollet have pointers?
|
I m not entirely certain that the ZEND_MOD_REQUIRED approach is necessarily the best in that case ... but I may look more into the sockets part itself and leave the dependency aspect to Remi, he probably knows better. |
3ed7c46 to
33de0ab
Compare
|
Instinctively speaking, I think this PR is fine (at least feature wise). However, I would like to see more test cases. e.g. what happens when you set TCP_NODELAY while curl se CURLOPT_TCP_NODELAY. What happens if you set the socket to blocking mode, what curl does ? |
33de0ab to
c576217
Compare
|
@devnexen new test cases added, with a little fix around socket closing. About custom options, libcurl seems to apply it's own option after the callback returns. So the implementation seems robust. |
a0dd708 to
54ee20d
Compare
|
@devnexen great catch on the sockopt exception path. You're right that it was inconsistent with For the stream-backed Socket case in opensocket — looking at The Following the same logic I noticed curl_multi_free_obj() had the same exposure: curl_multi_cleanup() closes the multi's pooled connections and fires the close callback of every attached easy handle, and at that point the Two small coverage additions while I was in: a CURL_SOCKOPT_ALREADY_CONNECTED return-value test (asserts the constant flows through to libcurl), and a curl_reset() test that confirms the socket FCC is dropped when the |
54ee20d to
44d3795
Compare
|
looking good in my opinion, note I am only judging the feature itself. |
|
Thanks a lot for your help and great feedbacks @devnexen |
|
We are working on something slightly related so please don't merge this until we get better idea about the implications. Basically we want to expose the underlying socket for polling but if it can be also represented with Socket, it might potentially complicate things. This is not a stop for this, we just need to check it out first. |
|
CC @arnaud-lb |
|
Also this is related note in updated docs: https://github.com/curl/curl/pull/22081/changes . I wonder if it could be potentially a bit confusing for users when different sockets are used. But it shouldn't be really an issue, just something to be aware of. |
|
It would be nice if this could be backported as a security "tool" to all supported PHP versions. We wanna use this in our application to protect against DNS-rebind/SSRF protection. Kinda callback to CURLOPT_OPENSOCKETFUNCTION and reject connections against private IPs. Doing this right now on user-land is pretty uggly and easy to mess up. |
|
@bukka thanks for the heads-up ! No-rush but the feature freezer for PHP 8.6 is coming quickly, maybe there is a draft PR or a sketch I can read to check if it fits well together ? I'm happy to adjust the api here if required to smooth things on your side (I may need some help/guidance but I'm always willing to help). @shyim that's a great idea and that's the exact use case I've in mind, I think this may be difficult regarding the version policy. Perhaps you may share this idea on internals (in reaction to my original thread) to get some feedback from maintainers ? |
7d7a01c to
41ebc20
Compare
41ebc20 to
e7bcbf1
Compare
|
@Girgias heeeelp 😄 I don't know how to get forward on this. I already sent 2 emails with exactly 0 answer on internals, WDYT ? Thanks ! |
Sjord
left a comment
There was a problem hiding this comment.
Looks good to me!
@arnaud-lb Could you take a look at this?
e7bcbf1 to
3777478
Compare
Do you have more information on this? A pull request link? Is this PR still good to go? |
Expose libcurl's CURLOPT_SOCKOPTFUNCTION, CURLOPT_OPENSOCKETFUNCTION and
CURLOPT_CLOSESOCKETFUNCTION, letting userland hook into socket creation,
configuration and teardown. These are useful for application security, in
particular SSRF protection (validating the resolved address before connecting)
and low-level socket hardening.
Following the existing curl_write_header / curl_prereqfunction bridge model, the
callbacks exchange ext/sockets Socket objects so they are fully usable in pure
PHP (socket_create/socket_bind, socket_set_option, socket_close):
- sockopt: fn(CurlHandle $ch, Socket $socket, int $purpose): int
returns CURL_SOCKOPT_OK / _ERROR / _ALREADY_CONNECTED
- opensocket: fn(CurlHandle $ch, int $purpose, array $address): Socket|false
$address = [family, socktype, protocol, ip, port];
returning false aborts the connection (CURL_SOCKET_BAD)
- closesocket: fn(CurlHandle $ch, Socket $socket): void
The dependency on ext/sockets is optional both at build and at runtime
(ZEND_MOD_OPTIONAL): the rest of ext/curl keeps working when sockets is not
loaded, and the three socket-callback options simply throw a clear Error when
invoked in that configuration. The Socket class entry is resolved lazily by
name at MINIT rather than referenced as a link-time symbol, so curl never
carries a hard symbol dependency on ext/sockets.
Notable details:
- Descriptors owned by libcurl are detached (bsd_socket = -1) before the
temporary Socket object is released, to avoid a double close. Socket
objects are created without calling socket_import_file_descriptor(), which
on Windows emits a spurious WSAEINVAL warning on a not-yet-connected
socket during the SOCKOPT phase.
- Pooled connections still alive at curl_easy_cleanup() would otherwise
invoke the userland CURLOPT_CLOSESOCKETFUNCTION callback during handle
destruction. Calling into PHP from there is unsafe (an exception thrown
from the callback would surface outside any try/catch). Setting the
option back to NULL on the easy handle is not enough — libcurl caches the
function pointer per connection. The close FCC is torn down before
curl_easy_cleanup() so the trampoline falls through to its native-close
fallback when libcurl invokes it.
- The same teardown is applied to every easy handle attached to a
CurlMultiHandle before curl_multi_cleanup() so the close callback never
fires from within the multi's destructor either.
- Stream-backed Sockets and already-closed Sockets returned from
CURLOPT_OPENSOCKETFUNCTION are refused with a TypeError: a stream-backed
Socket bypasses our bsd_socket detach (socket_free_obj() delegates close
to the backing php_stream), and an already-closed Socket would bury the
real cause under a generic CURLE_COULDNT_CONNECT.
- When the sockopt callback throws, the abort path returns
CURL_SOCKOPT_ERROR (matching opensocket / ssh_hostkey) so libcurl does
not connect with a half-configured socket.
- When the closesocket callback throws, the trampoline reports the failure
to libcurl (return 1, per CURLOPT_CLOSESOCKETFUNCTION's documented
contract). The descriptor is still closed by the native fallback, so
signalling an error cannot leak it.
- Setting an option to null restores libcurl's native default.
New constants (only defined when ext/curl is built with sockets headers
available, i.e. HAVE_SOCKETS): CURLOPT_SOCKOPTFUNCTION,
CURLOPT_OPENSOCKETFUNCTION, CURLOPT_CLOSESOCKETFUNCTION, CURL_SOCKOPT_OK,
CURL_SOCKOPT_ERROR, CURL_SOCKOPT_ALREADY_CONNECTED, CURLSOCKTYPE_IPCXN,
CURLSOCKTYPE_ACCEPT.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3777478 to
278bd5b
Compare
|
@Sjord thanks for the ping of @bukka and @arnaud-lb. It seems that everyone has their hands full. How can I help to get this merged into 8.6 ? I've been told that this may go after feature freeze, but 2 betas already have been released. I hope this change can make it soon. |
|
@mbeccati Could you take a look at this? It is basically done and it would be nice if it went into PHP 8.6, but it's been stuck for a while for unclear reasons. |
|
@Sjord I don't think we can safely merge, unless we've got green lights from @bukka and @arnaud-lb. Sorry, we can't just bypass that. |
|
Sorry, we are super busy with security stuff. I'm honestly not so sure if mixing up curl and socket ext object is a good idea and if it wouldn't be better to have our limited abstraction where we control it rather than providing user callback. The thing that worries me is is what happens if you keep the created socket so the object is alive and then the IO is mixed up with the curl execution. It might get even messier if such socket gets turned into the stream. I actually let Claude do the investigation exactly in that area - it didn't find an IO issue (it wasn't doing too much testing though) but it found double close as well as other issues when just CURLOPT_SOCKOPTFUNCTION. This was a really quick scan but it shows to me that it's not exactly ready and we might need to think about this more especially if we are considering including the hooks / reactor stuff. So it will likely need delay. Here are some PoC example that it gave it to me: <?php
/* PoC: the Socket handed INTO the sockopt callback can be turned into a stream
* with socket_export_stream(). That stream keeps its own copy of curl's fd, so
* the extension's bsd_socket=-1 detach no longer protects it: curl and the
* stream both close the same descriptor number. After fd reuse the stream's
* second close destroys an unrelated resource.
*/
$port = 18093;
$pid = pcntl_fork();
if ($pid === 0) { server($port); exit; }
usleep(300000);
$keep = null;
$ch = curl_init("http://127.0.0.1:$port/");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SOCKOPTFUNCTION, function ($ch, $s, $p) use (&$keep) {
$keep = socket_export_stream($s); // stream now owns curl's fd
return CURL_SOCKOPT_OK;
});
var_dump(curl_exec($ch)); // curl finishes, but still owns the fd
unset($keep); // (1) stream dtor closes curl's fd N
// -> now closed once, while curl still thinks it owns it
$victim = fopen(__FILE__, 'r'); // (2) OS hands fd N to this file
var_dump(fread($victim, 5)); // reads "<?php" fine
unset($ch); // (3) curl closes fd N a SECOND time == closes $victim's fd
var_dump(fread($victim, 5)); // victim's descriptor was pulled out from under it
echo "done\n";
pcntl_wait($st);
function server(int $port): void {
$s = stream_socket_server("tcp://127.0.0.1:$port", $e, $m);
if (!$s) exit(1);
$c = stream_socket_accept($s, 5);
if (!$c) exit;
$req = '';
while (($l = fgets($c)) !== false && rtrim($l) !== '') $req .= $l;
fwrite($c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
fclose($c);
}<?php
/* PoC: use-after-free via a share handle. The share owns the connection cache
* (CURL_LOCK_DATA_CONNECT). Freeing the easy handle leaves a pooled connection
* whose closesocket_client points at it; curl_share_cleanup() then fires the
* close callback on freed memory. No teardown exists in curl_share_free_obj().
*/
$port = 18092;
$pid = pcntl_fork();
if ($pid === 0) { server($port); exit; }
usleep(300000);
$sh = curl_share_init();
curl_share_setopt($sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT);
$ch = curl_init("http://127.0.0.1:$port/");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SHARE, $sh);
curl_setopt($ch, CURLOPT_CLOSESOCKETFUNCTION, function ($ch, $s) { /* noop */ });
var_dump(curl_exec($ch));
unset($ch); // easy handle (clientp) freed; connection lives on in the share
echo "easy handle freed; connection still held by the share\n";
unset($sh); // curl_share_cleanup() -> close cb on freed handle == UAF
echo "share freed\n";
if (function_exists("posix_kill")) posix_kill($pid, SIGKILL); pcntl_wait($st);
function server(int $port): void {
$s = stream_socket_server("tcp://127.0.0.1:$port", $e, $m);
if (!$s) exit(1);
$c = stream_socket_accept($s, 5);
if (!$c) exit;
for ($i = 0; $i < 50; $i++) {
$req = '';
while (($l = fgets($c)) !== false && rtrim($l) !== '') $req .= $l;
if ($req === '') break;
fwrite($c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok");
}
}<?php
/* PoC: use-after-free when a curl easy handle with CURLOPT_CLOSESOCKETFUNCTION
* is removed from a multi handle and freed, while its keep-alive connection
* stays in the multi's connection pool. curl_multi_cleanup() later fires the
* close callback with a clientp pointing at the freed handle.
*
* Run under valgrind to see the invalid read; may also just crash.
*/
$port = 18091;
$pid = pcntl_fork();
if ($pid === 0) { server($port); exit; } // child: server
usleep(300000); // let the server bind
$ch = curl_init("http://127.0.0.1:$port/");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CLOSESOCKETFUNCTION, function ($ch, $s) { /* noop */ });
$mh = curl_multi_init();
curl_multi_add_handle($mh, $ch);
do { curl_multi_exec($mh, $running); if ($running) curl_multi_select($mh); } while ($running);
var_dump(curl_multi_getcontent($ch));
curl_multi_remove_handle($mh, $ch); // connection stays pooled in $mh
unset($ch); // easy handle (clientp) is freed here
echo "easy handle freed; connection still pooled in the multi\n";
unset($mh); // curl_multi_cleanup() -> close cb on freed handle == UAF
echo "multi freed\n";
if (function_exists("posix_kill")) posix_kill($pid, SIGKILL); pcntl_wait($st);
function server(int $port): void {
$s = stream_socket_server("tcp://127.0.0.1:$port", $e, $m);
if (!$s) exit(1);
$c = stream_socket_accept($s, 5);
if (!$c) exit;
for ($i = 0; $i < 50; $i++) {
$req = '';
while (($l = fgets($c)) !== false && rtrim($l) !== '') $req .= $l;
if ($req === '') break;
fwrite($c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nok");
}
} |
|
We should introduce only |
|
Just to clarify, this is not like rejection of this. I think we can make it safe but we need to look really in detail on the implementation and what could be the side effects and make sure it cannot crash. Unfortunately there is not much time and we need to deal with security issues right now and then look more into the async stuff. |
|
@bukka if you're open to @arnaud-lb suggestion, I may work on this next week, including the risk of use after free that you pointed in your previous comment. WDYT ? Thanks to both of you for your feedbacks, good luck with the security issues. |
Summary
This PR exposes libcurl's three socket-level callback options, which were previously unavailable in PHP:
CURLOPT_SOCKOPTFUNCTION— invoked after a socket is created but before it is connected, to tune low-level socket options.CURLOPT_OPENSOCKETFUNCTION— invoked to create the socket for a connection, after the address has been resolved but beforeconnect().CURLOPT_CLOSESOCKETFUNCTION— invoked when libcurl is done with a socket.The main motivation is application security.
CURLOPT_OPENSOCKETFUNCTIONin particular lets an application inspect the resolved IP address and refuse the connection, which is the robust way to implement SSRF protection (it happens after DNS resolution, so it also defeats DNS-rebinding to internal addresses — something hostname/URL allow-listing cannot do).CURLOPT_SOCKOPTFUNCTIONallows socket hardening (SO_BINDTODEVICE, keep-alive, packet marks, …).API
The C callbacks take/return a raw
curl_socket_tfile descriptor, which pure PHP cannot create or read. To make the options usable from plain PHP, the callbacks exchange ext/socketsSocketobjects, built on top of the C API alreadyexported by ext/sockets (
socket_ce, thephp_socketstruct andsocket_import_file_descriptor()):Example: blocking private/reserved IPs (SSRF protection)
Because
CURLOPT_OPENSOCKETFUNCTIONreceives the resolved address, it can reject any request that would reach a private or reserved range — even if the attacker supplied a public hostname that resolves to an internal IP:Implementation notes
HAVE_SOCKETS. ext/curl gains aZEND_MOD_REQUIRED("sockets")dependency (plusPHP_ADD_EXTENSION_DEP); when built without ext/sockets, curl still builds, just without these options.bsd_socket = -1) before the temporarySocketobject is released, to avoid a double close.CURLOPT_CLOSESOCKETFUNCTIONis disabled right beforecurl_easy_cleanup(), so libcurl closes pooled sockets natively instead of calling into PHP while the handle is being destroyed (which can happen during GC/shutdown). The callback still fires for sockets closed during a transfer.nullrestores libcurl's native default.CURLOPT_SOCKOPTFUNCTION,CURLOPT_OPENSOCKETFUNCTION,CURLOPT_CLOSESOCKETFUNCTION,CURL_SOCKOPT_OK,CURL_SOCKOPT_ERROR,CURL_SOCKOPT_ALREADY_CONNECTED,CURLSOCKTYPE_IPCXN,CURLSOCKTYPE_ACCEPT.Tests
Added
.phptcoverage for each option (success, abort/error paths, invalid return types/values,null,curl_copy_handle) plus a trampoline test. The fullext/curlsuite passes with no regressions.Fixes: https://bugs.php.net/bug.php?id=62906