// HackTricks · Network Services

Disable Functions Bypass - dl Function

Disable Functions Bypass - dl Function

dl() lets PHP load a shared extension at runtime. If you can make it load an attacker-controlled module, you can register a new PHP function that internally calls execve, system, or any other native primitive and therefore bypass disable_functions.

This is a real primitive, but on modern targets it is far less common than older writeups suggest.

Why this bypass is uncommon today

The main blockers are:

  • dl() must exist and must not be disabled
  • enable_dl must still allow dynamic loading
  • The target SAPI must support dl()
  • The payload must be a valid PHP extension compiled for the same target ABI
  • The extension must be reachable from the configured extension_dir

The official PHP manual is the most important reality check here: dl() is only available for CLI and embed SAPIs, and for the CGI SAPI when run from the command line. That means the technique is usually not available in normal PHP-FPM/mod_php web requests, so check the SAPI before spending time building a payload.[1]

Also note that enable_dl is an INI_SYSTEM setting and, as of PHP 8.3.0, PHP documents it as deprecated, so you usually cannot flip it at runtime from attacker-controlled PHP code.

If dl() is not viable, go back to the broader list of module/version dependent bypasses.

Fast triage from a foothold

Before building anything, collect the exact parameters that the module must match:

<?php
phpinfo();
echo "PHP_VERSION=" . PHP_VERSION . PHP_EOL;
echo "PHP_SAPI=" . php_sapi_name() . PHP_EOL;
echo "ZTS=" . (PHP_ZTS ? "yes" : "no") . PHP_EOL;
echo "INT_BITS=" . (PHP_INT_SIZE * 8) . PHP_EOL;
echo "enable_dl=" . ini_get("enable_dl") . PHP_EOL;
echo "extension_dir=" . ini_get("extension_dir") . PHP_EOL;
echo "disabled=" . ini_get("disable_functions") . PHP_EOL;
?>

What you care about:

  • PHP_SAPI: if this is fpm-fcgi or apache2handler, dl() is usually a dead end for web exploitation
  • extension_dir: the payload must be loaded from here
  • PHP Version, architecture, debug/non-debug, and ZTS/non-ZTS: your module must match them
  • disable_functions: confirm whether dl is absent because it is disabled or because the SAPI does not support it

Practical exploitation constraints

1. You normally need write access to extension_dir

This is the biggest bottleneck.

dl() takes the extension filename, and PHP loads it from extension_dir. In practice, this means that a normal arbitrary file upload to /var/www/html/uploads is not enough. You still need a path to place a .so/.dll where PHP will actually load extensions from.

Realistic situations where this becomes exploitable:

  • CTFs or intentionally weak labs where extension_dir is writable
  • Shared-hosting or container mistakes that expose a writable extension path
  • A separate arbitrary file write primitive that already reaches extension_dir
  • Post-exploitation scenarios where you already escalated enough to drop files there

2. The module must match the target build

Matching only PHP_VERSION is not enough. The extension also needs to match:

  • OS and CPU architecture
  • libc/toolchain expectations
  • ZEND_MODULE_API_NO
  • debug vs non-debug build
  • ZTS vs NTS

If those do not match, dl() will fail or crash the process.

3. open_basedir is not the main defense here

Once you can place the module in extension_dir and call dl(), the extension code executes inside the PHP process. At that point the relevant barrier was not open_basedir, but the ability to land a valid shared object in the extension loading path.

Building the malicious extension

The classic route is still valid in the constrained environments described above:[2]

  1. Recreate the victim build as closely as possible
  2. Use phpize, ./configure, and make to build a shared extension
  3. Export a PHP function such as bypass_exec($cmd) that wraps native command execution
  4. Upload the compiled module into extension_dir
  5. Load it with dl() and call the exported function

The primitive still exists; the hard part is finding a supported SAPI where it is reachable and landing a matching module in extension_dir.[1][2]

Minimal workflow

On the attacker box

mkdir bypass && cd bypass
phpize
./configure
make

The resulting shared object will usually be under modules/.

If you are building on a different environment than the target, treat the produced file as a draft until you verify that the ABI matches the victim.

Loading and using the extension

If the target really supports dl() and the module is inside extension_dir, the runtime side is simple:

<?php
if (!extension_loaded('bypass')) {
    dl('bypass.so'); // use the correct filename for the target platform
}
echo bypass_exec($_GET['cmd']);
?>

On Windows the filename will typically be a .dll, while on Unix-like targets it will usually be a .so.

Attacker notes

  • Do not assume this works remotely just because function_exists("dl") returns true in some documentation or old writeup; validate the live SAPI
  • A failed dl() attempt may kill the PHP worker if the module is incompatible
  • From PHP 8 onward, disabled functions are removed from the function table, so userland enumeration may differ from older posts
  • If you cannot write to extension_dir, this technique is usually less practical than FPM/FastCGI, LD_PRELOAD, or module-specific bypasses already covered in this section

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.