[php-src] Issue #22035: CURLOPT_SSLENGINE loads arbitrary shared objects

[email protected] (thomas-chauchefoin-tob)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/22035
Author: thomas-chauchefoin-tob

### Description

`ext/curl` extension passes `CURLOPT_SSLENGINE` directly to `libcurl`. When the OpenSSL backend is used, `libcurl` forwards the value to OpenSSL's dynamic engine loader, ultimately calling `dlopen()` on the path. This allows executing native code from PHP code subject to `open_basedir` / `disable_functions (which are not security boundaries anyway).

This `libcurl` option is documented in https://curl.se/libcurl/c/CURLOPT_SSLENGINE.html. When `libcurl` is built with OpenSSL, this calls `ENGINE_by_id`:

```c
static CURLcode ossl_set_engine(struct Curl_easy *data, const char *name)
{
#ifdef USE_OPENSSL_ENGINE
  CURLcode result = CURLE_SSL_ENGINE_NOTFOUND;
  ENGINE *e = ENGINE_by_id(name);
  // (...)
}
```
https://github.com/curl/curl/blob/6f1dfab6a29242525cdc7b48f5ee49269b9cc316/lib/vtls/openssl.c#L1657-L1693

Internally, if OpenSSL can't find the engine in the default path, it loads the `dynamic` engine support and uses control commands to load `id`:

```c
if (strcmp(id, "dynamic")) {
    if ((load_dir = ossl_safe_getenv("OPENSSL_ENGINES")) == NULL)
        load_dir = ossl_get_enginesdir();
    iterator = ENGINE_by_id("dynamic");
    if (!iterator || !ENGINE_ctrl_cmd_string(iterator, "ID", id, 0) || !ENGINE_ctrl_cmd_string(iterator, "DIR_LOAD", "2", 0) || !ENGINE_ctrl_cmd_string(iterator, "DIR_ADD", load_dir, 0) || !ENGINE_ctrl_cmd_string(iterator, "LIST_ADD", "1", 0) || !ENGINE_ctrl_cmd_string(iterator, "LOAD", NULL, 0))
        goto notfound;
    return iterator;
}
```
https://github.com/openssl/openssl/blob/fe686e15d84334b284f883118ed92f64b409b3aa/crypto/engine/eng_list.c#L460-L466

Then the dynamic loader uses `DSO_load` with the absolute path as the second parameter `merge`:

```c
char *merge = DSO_merge(ctx->dynamic_dso, ctx->DYNAMIC_LIBNAME, s);
if (!merge)
    return 0;
if (DSO_load(ctx->dynamic_dso, merge, NULL, 0)) {
```
https://github.com/openssl/openssl/blob/fe686e15d84334b284f883118ed92f64b409b3aa/crypto/engine/eng_dyn.c#L379-L382


Internally, `DSO_load` calls `dlfcn_load`, which in turn calls `dlopen` and loads the library:

```c
static int dlfcn_load(DSO *dso)
{
    // (...)
    char *filename = DSO_convert_filename(dso, NULL);
    // (...)
    ptr = dlopen(filename, flags);
```
https://github.com/openssl/openssl/blob/fe686e15d84334b284f883118ed92f64b409b3aa/crypto/dso/dso_dlfcn.c#L93-L113 

I have a patch that prevents any path separator in this option, but that may be too restrictive? I'll open a PR later this week if not.

### PHP Version

```plain
PHP 8.6.0-dev (cli) (built: May 10 2026 23:58:07) (NTS)
Copyright © The PHP Group and Contributors
Zend Engine v4.6.0-dev, Copyright © Zend by Perforce
    with Zend OPcache v8.6.0-dev, Copyright ©, by Zend by Perforce
```

### Operating System

_No response_
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.