The printer goes brrrrr, again!

Rédigé par Rémi Jullian , Mehdi Talbi , Thomas Jeunet - 12/05/2023 - dans Exploit - Téléchargement

For the second time at Pwn2Own competition, network printers have been featured in Toronto 2022. The same brands were included this year as in Austin 2021: HP, Lexmark and Canon with equivalent model. Unlike the previous event, we only targeted the Lexmark and Canon but nevertheless manage to compromise both. Sadly, the bug we exploited for the Canon printer was previously used by another team in the competition. Anyway, this is how we achieved code execution on the Canon printer.

If you are interested in how to bootstrap this research, refer to our other article. This article along with the published tools might explain why we have seen so many entries on this target during the last edition: 14.

For this Pwn2Own edition, we targeted another protocol available by default on the printer and prominent in many networks: NetBIOS.

NetBIOS

NetBIOS, strictly speaking, is an API providing different services (Name service, Datagram distribution service and Session service) that can run over multiple network protocols. Nowadays only NetBIOS level 2 over TCP/IP (NBT) remains, and here we will focus on the name service that allows name registration and resolution. NetBIOS communication happens between names that have to be previously resolved.

Implementation in DryOS

From the debug strings, it appears that NetBIOS implementation in Dry-OS is based on an app called netcifsnqendapp:

After a brief research, this implementation looks to be closed source. However, abusive usage of log-related functions allows retrieving functions name as well as file names where functions were implemented. This section will introduce how the NetBIOS protocol is implemented within Dry-OS.

Context allocation

The function initialize implemented in nddaemon.c calls three functions in order to allocate custom contexts : ndDatagramInit, ndNameInit and ndAdapterListInit.

ndDatagramInit allocates 2 arrays of chars of 255 bytes each, given as parameters to cmNetBiosParseName (alloc of 510 bytes). The function is used to decode a NetBIOS name and will be described later in the blogpost.

ndNameInit calls ndInternalNameInit which allocates a table of 20 entries of type netbios_internal_name_entry (100 bytes per entry) used to store internal netbios names (i.e related to the printer). By default 2 entries are populated, the NetBIOS name of the printer (e.g "CANONA5A2C6") and the NetBIOS workgroup to which the printer belongs (e.g "WORKGROUP").

ndNameInit also calls ndExternalNameInit which allocates table of 20 entries of type netbios_external_name_entry (88 bytes per entry), used to store resolved external NetBIOS name request (i.e entries not related to the printer).

Finally, ndAdapterListInit allocates an adapter table used to store up to 2 adapters context.

Once all contexts have been allocated, the function CreateService is called 4 times, with a custom identifier, in order to create sockets and bind on ports:

Identifier Binding address Description
0 0.0.0.0:137 (UDP) Implement NetBIOS name service
1 0.0.0.0:138 (UDP) Implement NetBIOS datagram service
3 127.0.0.1:1022 (UDP) Implement a local NetBIOS resolver (handle request from nsGetHostByName)
4 127.0.0.1:1023 (UDP) Implement a NetBIOS datagram forwarder (handle request from nsSendToName)

 

NetBIOS name registration

The first entry of type netbios_internal_name_entry will be announced on the network using ndInternalNameRegister, which sends a NetBIOS Name Registration (Claim) request on port UDP/137 as described in RFC 1001 - 15.1.1. The call stack associated with NetBIOS internal name registration is:

 

initialize
    processConfigChange
        ndInternalNameRegisterAllNames
            ndInternalNameRegister
                sendRegistrationRequest
                    sySendToSocket

Packet reception

The task dedicated to NetBIOS uses sySelectSocket in order to monitor the 4 file descriptors created using CreateService. Once a fd is ready to be read, a call to syRecvFromSocket is made with a fixed length of 1500 bytes. According to the fd on which the packet was received, one of the following functions is called:

0.0.0.0:137 (UDP) ndNameProcessExternalMessage
0.0.0.0:138 (UDP) ndDatagramProcessExternalMessage
127.0.0.1:1022 (UDP) ndNameProcessInternalMessage
127.0.0.1:1023 (UDP) ndDatagramProcessInternalMessage

From an attacker point of view, it is thus interesting to look at both ndNameProcessExternalMessage and ndDatagramProcessExternalMessage as they implement a network protocol that does not support authentication, and is not likely to be filtered. By looking at these functions we quickly found some references to cmNetBiosParseName, thus we decided to look at how NetBIOS name encoding works under the hood.

NetBIOS name encoding

According to RFC 1001, there are two levels of encoding. The first level maps a 16 bytes NetBIOS name into a 32 byte wide field using a reversible, half-ASCII, biased encoding:

netbios first level encoding

For example, using this encoding scheme the NetBIOS name SYNACKTIV maps to FDFJEOEBEDELFEEJFGCACACACACACACA

The second level maps the domain system name into the "compressed" representation required for interaction with the domain name system. The RFC 883 depicts the format of a domain name. According to this RFC, a domain name is expressed in terms of a sequence of labels. Each label is represented as a one octet length field followed by that number of octets. Actually, the length field is a 6-bit field. If the high order two bits of the length field are set to 1, then the following 14 bits are an offset pointer into the full message to the actual label string from another domain name that belongs in this name. A zero length value indicates the root label which is always null.

For instance, the NetBIOS name SYNACKTIV.synacktiv.synacktiv.com is encoded into a NetBIOS packet as following:

NetBIOS packet

The vulnerability

The vulnerability is present inside the cmNetBiosParseName function:

This function performs the first level decoding of the first label. If the subsequent label does not start with a NUL byte, then the function continues with the second level decoding. Each label is decoded in the resolveLabel function. This function returns a pointer to the label start from which its length is read. The decoded label is then copied into an allocated buffer of 255 bytes if there is still enough space in the destination buffer. However, if the length of the label field is zero, then no copy will take place but the destination buffer (second_level_name) will be increased by one. This scenario is illustrated by the following picture.

NetBIOS domain name representation and compression

After decoding the first level label, the parser will encounter a label offset (i.e., high order two bits of the length field is set to 1) that points to a NUL byte. In that case no byte will be copied, but the destination buffer will be increased by one.  If we repeat this process several times, then we can increase the destination buffer by 255 bytes. A  following valid label will be therefore copied beyond the limit of the allocated buffer which leads to a heap-based overflow.

Luckily, the order of allocations made during NetBIOS context initialization helps us as the destination buffer (second_level_name) comes from an allocation followed in memory by an array of netbios_internal_name_entry structure, which is interesting to override in an exploitation scenario !

Please not that due to scheduling issue, a context switch may occur in a rare few cases between the 2 allocations which would make these 2 allocations not contiguous in memory.

The call stack allowing to trigger the vulnerability is given hereafter:

ndStart
    syRecvFromSocket
    ndNameProcessExternalMessage
        cmNetBiosParseName

Exploitation

The heap overflow described in the section below allows overriding (partially) an array of 20 structures of type netbios_internal_name_entry. Each structure is 0x64 bytes long, and overriding only one allows getting a Write-What-Where (WWW) primitive of 2 bytes. The way to obtain this is by sending a Positive Name Query Response (RFC 1002 - Section 4.2.13), with a corrupted state, in order to trigger a Negative Name Query Response (RFC 1002 - Section 4.2.14), that will write 2 bytes of controlled data, at a controlled address. The 2 bytes written will be the Transaction ID specified in the Positive Name Query Response, in order to build the header (RFC 1002 - Section 4.2.2.1) of the Negative Name Query Response.

The call stack allowing to trigger this write primitive is:

ndStart
    syRecvFromSocket
    ndNameProcessExternalMessage
        ndInternalNamePositiveQuery
            returnNegativeRegistrationResponse

In the netbios_internal_name_entry structure, several fields are important in order to obtain this primitive:

NetBIOS internal name

1. The NetBIOS name, an array of 16 bytes specified in the query, and located in the structure at offset 0x04. This name is used to find the matching netbios_internal_name_entry structure in the array of 20 entries.

2. A pointer to a netbios_adapter structure, located at offset 0x24. This structure contains another pointer at offset 0x38, used to write the Negative Name Query Response payload. Usually, this pointer is used to build a NetBIOS response payload, and the first 2 bytes are set to the Transaction ID (NAME_TRN_ID as defined in RFC 1002, Section 4.2.2.1). In the context of the exploit, this pointer defines the address where the 2 controlled bytes will be written (the “WHERE”). The fake netbios_adapter structure will be written in memory using the BJNP protocol, allowing writing controlled data at a fixed address.

3. An uint16_t field, located at offset 0x20 allowing to write 2 bytes (the “WHAT”) at the address pointed by the pointer written previously.

4. An uint16_t field, located at offset 0x2c, allowing to pass a state check (must be set to 8) to reach the function returnNegativeRegistrationResponse.

5. An uint16_t field, located at offset 0x3c, whose value is the expected transaction request id, specified in the request.

Using the primitive twice allows to override a function pointer (4 bytes). The pjcc_dec_ope_echo function pointer, related to the PJCC protocol, is targeted in order to redirect the execution flow to a shellcode.

Thus, the exploitation steps are the following:

1. Use the heap-overflow vulnerability to prepare the WWW allowing to write the 2 lower-bytes of the function pointer pjcc_dec_ope_echo

2. Trigger the WWW by sending a Positive Name Query Response

3. Use the heap-overflow vulnerability again, to prepare the WWW with the WHERE address now pointing on the 2 upper-bytes of the function pointer pjcc_dec_ope_echo

4. Trigger the WWW by sending another Positive Name Query Response

5. Send a BJNP SessionStart message to store the shellcode at a fixed address

6. Send a PJCC ECHO message. The overridden function pointer leads to shellcode execution.

The scenario is summarized in the following figure:

Exploit scenario

 

We used the same post exploitation as in Austin 2021, so you can refer to our previous article for details.

And ... here is our famous ninja, displayed on the printer's screen after successfully exploiting the vulnerability during our Pwn2Own attempt :

Synacktiv ninja displayed on the printer screen

 

Such as for our previous exploit, we released the source code on our Github repository:

The patch

During the Pwn2Own contest, (9th December 2022) we targeted the firmware in version 11.04, latest firmware available at that time. On the 14th of April 2023, Canon released a note mentioning security fixes (likely) related to the Pwn2Own, with a list of 10 CVEs including 6 buffer overflows affecting our device:

  • CVE-2023-0851
  • CVE-2023-0852
  • CVE-2023-0853
  • CVE-2023-0854
  • CVE-2023-0855
  • CVE-2023-0856

Thus, we decided to download the new firmware version (12.03) in order to check if our vulnerability has been patched, and to confirm that indeed, we had a collision with another team. Please note that at the time of writing, this new firmware was not directly available via an OTA update. Thus, we downloaded Firmware Update Tool V12.03 from Canon website, and performed the upgrade using USB.

We directly looked at the vulnerable function and checked the modification related to the vulnerable function cmNetBiosParseName:

cmNetBiosParseName

With the new patch, specifying a label length (label_len) of 0 will now decrement the field remaining length (len_remaining_255) of 1. Thus it's not possible anymore to increment the destination pointer, without decrementing the field remaining length.