Exploiting deobfuscation in ImunifyAV for code execution (CVE-2025-65530)

11 min read Original article ↗

ImunifyAV is a free Linux malware scanner targeting servers running PHP web applications. It is pretty common in the shared hosting world, as it is installed by default in both cPanel and Plesk, two popular "control panels" for managing a multi-tenant web application hosting environment on a single Linux machine.

In October 2025, I discovered a vulnerability in ImunifyAV that enabled privilege escalation and remote code execution. I reported the vulnerability to CloudLinux, the developers of ImunifyAV, and it has now been fixed. In this post I'll go over the bug and share some neat details about writing a functional exploit that have not yet been reported publicly.

AI-Bolit and its deobfuscation routines

One of the components of ImunifyAV is AI-Bolit1, a command-line malware scanner written in PHP. AI-Bolit scans files against known malware signatures, but before performing the scan, it will also attempt to undo some simple obfuscation techniques to make the signatures more robust to threat actors re-obfuscating the same malware with different tools or settings.

Being a PHP application, we can pop it open in an editor and look for interesting code. Like this function:

<?php
public static function executeWrapper(string $func, array $params = [])
{
    $res = '';
    try {
        $res = call_user_func_array($func, $params);
    } catch (Throwable $e) {
    }
    return $res;
}

This is a wrapper around call_user_func_array, which calls a function (specified by name, as a string) with the supplied arguments. If we could reach executeWrapper while having some control over $func and $params, we would get code execution.

Where is executeWrapper called from? The deobfuscators. Grepping the code, we find many callsites that look something like this:

<?php
$func = $matches[2];

if (Helpers::convertToSafeFunc($func)) {
    $result = @Helpers::executeWrapper($func, [$matches[3]]);

I won't bore you by listing convertToSafeFunc, but it's an allowlist check that limits $func to the names of a few side-effect-less functions like hex2bin, base64_decode, and so on. If we just keep looking, though, we find a call site2 where this check is absent:

<?php
private function deobfuscateDeltaOrd($str, $matches)
{
    $matches[4] = str_replace(' ', '', $matches[4]);
    if (isset($matches[3]) && $matches[3] !== '') {
        $funcs = array_reverse(array_filter(explode('(', $matches[6])));
        $str = $matches[7];
        foreach ($funcs as $func) {
            $str = Helpers::executeWrapper($func, [$str]);
        }
    } else {

If we can set $matches[6] to "foo(bar(baz", this will end up calling foo(bar(baz($matches[7]))). Can we? Searching for deobfuscateDeltaOrd yields no direct calls, but searching for deltaOrd surfaces this entry on a huge list:

<?php
  348 => 
  array (
    'fast' => '~function\\s*(\\w+)\\((\\$\\w+)\\)\\s*\\{\\s*(?:\\2=gzinflate\\(base64_decode\\(\\2\\)\\);|\\$\\w+\\s*=\\s*base64_decode\\[...snip...]?~msi',
    'full' => '~function\\s*(\\w+)\\((\\$\\w+)\\)\\s*\\{\\s*(?:\\2=gzinflate\\(base64_decode\\(\\2\\)\\);|\\$\\w+\\s*=\\s*base64_decode\\[...snip...]?~msi',
    'id' => 'deltaOrd',
  ),

That's a regular expression! The $matches array makes perfect sense now: something must be matching the regular expression against the file being scanned and calling deobfuscateDeltaOrd with the captured groups if successful.

That's a pretty complex 526-character regex, and there are probably tools out there that could produce a matching string automatically, but I just threw it into regex101 and reverse-engineered it by hand, using the regex debugger when I got stuck. I ended up with something like this, with $matches[6] and $matches[7] boxed:

function wrd($ar) {
  $a = '';
  for ($i = 0;$i < strlen($ar);$i++) {
    $a[$kd] = chr(ord($ar[$i]) - 3);
  }
  return $a;
}
eval(wrd(exec("bash -c id > /tmp/hi.txt"))));

(Don't think too hard about what this code does—it does not do anything, because it's not meant to ever be executed, only to be matched against the regex and populate $matches[6] and $matches[7] with what we want.)

Putting this snippet in a file and manually running php ai-bolit.php -y -j exploit.txt does indeed cause /tmp/hi.txt to be created, meaning we have successfully achieved code execution. Right?

The exploit does not work

After CloudLinux had released a patch but before they had published an advisory, Patchstack diffed the patch to write an advisory of their own. They included a PoC very similar to the one I show above.

Fortunately for users who had not installed the patch yet, the PoC does not really work. Sure, running the AI-Bolit CLI directly against it triggers the code execution, but the vast majority of ImunifyAV users have no idea about AI-Bolit. They rely on scans that can be triggered through the ImunifyAV dashboard, manually or on a schedule.

Those scans do end up shelling out to AI-Bolit, so it makes sense to think that the exploit would still work. But that's not what happens: scans triggered from the UI complete successfully, yet our code does not execute. I spent an evening tearing my hair out trying to figure out why, until I started questioning whether ai-bolit was running at all. A close look at the process tree clarified things: the CLI was being invoked like this (formatted for clarity):

php -d display_errors=stderr \
    -d display_startup_errors=stderr
    -n \
    -d short_open_tag=on \
    -d extension=posix \
    -d extension=zip \
    -d extension=hyperscan \
    -d disable_functions=pcntl_exec,popen,exec,system,passthru,proc_open,shell_exec,ftp_exec,phpinfo,ini_restore,dl,symlink,chgrp,putenv,getmyuid,posix_setsid,posix_setpgid,apache_child_terminate,virtual,proc_close,proc_get_status,proc_terminate,proc_nice,getmygid,proc_getstatus,escapeshellarg,show_source,pclose,get_current_user,getmyid,pfsockopen,syslog,phpcredits,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_getpriority,pcntl_setpriority \
    /opt/ai-bolit/ai-bolit-hoster.php --smart --deobfuscate --avdb [...snip...]

disable_functions is a PHP setting that completely removes the specified functions from the standard library. It's a pretty long list, but exec and its friends are right there at the top. How hard could it possibly be to bypass a function blocklist?

It's pretty hard

So what we have is the ability to execute a PHP function of our choice, subject to a blocklist, with exactly one argument, which can be either a string of our choice or the result of another function, and what we want is security impact. Here are some things that do not work:

  • system, exec, and friends are all on the blocklist.
  • eval is a special language construct (like echo), not a function, and cannot be called in this way.
  • If you search for "php disable_functions bypass" on the Internet, you will eventually find Chankro, a tool that exploits the fact that PHP's built-in mail function shells out to a sendmail binary, so if you can set the LD_PRELOAD environment variable to a malicious library you control, the code in the library will be executed too. In our case we have no general way of dropping a library on the host, and anyhow putenv is on the blocklist too.
  • In PHP<8.0.0, assert had this goofy behavior where the assertion can be a string, in which case it is passed through eval. But ImunifyAV ships PHP 8.
  • I had this crazy idea that I could call set_error_handler("some_func") and then use trigger_error($foo) to call some_func(_, $foo, _, _, _, _) and reach some functions that were previously unavailable to me, but the restrictions on that function's signature (needs to be happy taking 5 arguments, of which we only really control the second one) seemed too severe.

At this point I was getting pretty desperate. I read through the entire list of standard library functions on php.net multiple times without finding anything exciting (I guess we can call unlink and delete a file? yay?). I scanned through all 1300+ function declarations in ai-bolit-hoster.php with no results either.

I did discover that PHP has pretty cool runtime reflection capabilities, which I could use to list all functions visible inside ai-bolit-hoster.php and filter them down to only those that don't require more than one parameter, which was mostly just repeating the same vibe-checking exercise from before but a bit more efficiently. In that process I noticed that AI-Bolit ships with a proprietary PHP extension that provides bindings to Intel's hyperscan regular expression engine, so I briefly imagined that this could turn out to be a story about memory unsafety, but the bindings did not contain interesting functions callable with just one argument.

Borrowing tricks from an entirely different bug class

One vaguely dangerous function that we could obviously call is unserialize. Calling unserialize on malicious input is a bad idea because it lets the attacker construct instances of arbitrary classes, and those classes might have code in their __wakeup or __destruct methods that will do something interesting when they are automatically executed by the PHP runtime. The exploitability of an unsafe unserialize call hinges entirely on whether such classes (called "gadgets") are present in the target application, which occasionally is the case in large frameworks. AI-Bolit actually had a bug very similar to ours disclosed back in 2021 where the sink was unserialize instead of call_user_func, but the reporters did not share any interesting exploitation paths. Looking through ai-bolit-hoster.php, no classes implemented __wakeup, and no instances of __destruct did anything fun.

unserialize can still be useful to us, though. Re-reading the documentation for call_user_func for the millionth time, I realized that the first parameter doesn't have to be a string. A PHP callable can be the name of a function, but it can also be an array like ["Class", "method"] to call Class->method(), or an array like [$instance, "method"] to call $instance->method(). By combining unserialize and call_user_func, we can construct an instance of any class and call any method on it (with no arguments, since we only control the first arg to call_user_func).

This, it turns out, was my lucky break. I turned to reflection again to list all classes that have methods I can call, and eventually I found this one:

<?php
/**
 * Class PlainReport report to text file
 */
class PlainReport extends Report
{
    [...]
    private $file;
    private $raw_report;
    [...]
    public function write()
    {
        $ret = '';
        if ($l_FH = fopen($this->file . '.tmp', "w")) {
            fputs($l_FH, $this->raw_report);
            fclose($l_FH);
        }
        if (rename($this->file . '.tmp', $this->file)) {
            $ret = "Report written to '$this->file'.";
        } else {
            $ret = "Cannot create '$this->file'.";
        }
        return $ret;
    }

With unserialize, we can construct an array containing a PlainReport with any values of file and raw_report we want, followed by the string "write". Then we can pass this array into call_user_func to call the method and overwrite an arbitrary file with content we fully control!

Another short PHP script can generate the unserialize payload, and then we can just stick it into the POC from before. Scanning it directly with the AI-Bolit CLI still drops a file into /tmp/hi.txt, but now so do manual and scheduled scans from ImunifyAV. Scheduled scans are enabled by default and run as root, which means we can overwrite all sorts of exciting files, like configs or /root/.ssh/authorized_keys. The only thing that stands between us and compromising a server running ImunifyAV, then, is a way to get our payload—around 600 ASCII bytes—into a file with any of a long list of common extensions that AI-Bolit will scan (including, for example, .txt). That's not entirely trivial, but web applications that support attachment uploads, write logs, or use files for caching could be at risk.3 And in a shared hosting setup, this would trivially let any customer escalate privileges to root and compromise all other customers on the same machine.

Disclosure

  • October 5, 2025: I reported the vulnerability to CloudLinux, as well as to WebPros International, developers of cPanel and Plesk. I shared my intention to disclose on a 90+30 timeline.
  • October 6, 2025: CloudLinux replied saying they will look into it.
  • October 20, 2025: WebPros International replied saying they'll let CloudLinux handle it.
  • October 23, 2025: CloudLinux released aibolit 32.7.4, containing a fix.
  • November 2, 2025: I followed up with CloudLinux to confirm they considered the issue fixed and remind them of my disclosure plans.
  • November 4, 2025: CloudLinux confirmed the fix and requested a delay of public disclosure "due to our slow update cycles and vendor dependencies." I agreed to delay disclosure until the 90-day mark.
  • November 12, 2025: Patchstack independently reverse-engineered the bug from CloudLinux's patch notes and published a writeup, including a partial POC.
  • November 17, 2025: CloudLinux published an advisory.
  • January 7, 2026: This post was published.
  1. Don't roll your eyes, it's been called that since 2012, I assume in reference to a character from a Russian children's poem.

  2. And another in deobfuscateEvalHexFunc that is very similar.

  3. Patchstack pointed out that the vulnerable code is also present in imunify_dbscan.php, which runs the same scans against database rows, and thus this vulnerability could potentially also be triggered through, say, blog comments. That's probably true, but I don't think the database scanner is enabled by default, though I haven't really looked into it much.