Talos Vulnerability Report

TALOS-2019-0918

Bitdefender BOX 2 bootstrap update_setup command execution vulnerability

January 21, 2019
CVE Number

CVE-2019-17102

Summary

An exploitable command execution vulnerability exists in the recovery partition of Bitdefender BOX 2, version 2.0.1.91. The API method /api/update_setup does not perform firmware signature checks atomically, leading to an exploitable race condition (TOCTTOU) that allows arbitrary execution of system commands. To trigger this vulnerability, an unauthenticated attacker can send a series of HTTP requests to the device while in the bootstrap stage.

Tested Versions

Bitdefender BOX 2, version 2.0.1.91

Product URLs

https://www.bitdefender.com/box/

CVSSv3 Score

9.0 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H

CWE

CWE-413: Improper Resource Locking

Details

Bitdefender produces Bitdefender BOX 2, a device aimed at protecting home networks from a variety of threats, such as malware, phishing websites and hacking attempts. It also provides a way to monitor specific devices in the network and limit their internet access. To achieve this, Bitdefender works as a gateway and splits the home network in two: a monitored network and an unmonitored network (where the main home router is). This way, it can inspect (and block) malicious traffic on the internet. They also provide an app called "Bitdefender Central app," available for Android and iOS, that can be used to manage the Bitdefender BOX from any location.

Bitdefender BOX 2 is based on "OpenWrt Chaos Calmer 15.05," running on a Mindspeed Comcerto 2000 processor, with its firmware stored in a 4GB NAND flash.

When the device is reset to factory settings, the bootstrap partition is used for booting up until the device is completely configured. In our test device, this partition currently contains an old version of the Bitdefender BOX 2 firmware: 2.0.1.91 (built on October 24th 2017).

At this stage, the device exposes an HTTP server (via OpenWRT's uhttpd, running as root) that uses Lua scripts (stored in /opt/bitdefender/www/lua/basic_ws) to handle the incoming requests from the smartphone app, needed to configure the device and perform firmware updates.

To start the setup process, the smartphone connects to the "Bitdefender BOX" SSID, created by the device itself, allowing the "Bitdefender Central app" to communicate to the box via HTTP. Then, in summary:

  • The smartphone app sends an encrypted and signed file called "full_ws.tar.gz" via the HTTP method /api/update_setup.
  • The device verifies the archive and triggers the script install_full_ws which will extract new files in /opt/bitdefender/www/ and /opt/bitdefender/share/scripts. In particular this creates a new full_ws directory, together with additional setup scripts, effectively replacing the current Lua logic used by uhttpd.
  • Any subsequent request will now be handled by a more up-to-date version of the Lua scripts.

The setup process continues using the new scripts, the next steps involve, in broad terms:

  • Fetching an updated firmware from remote servers (nimbus.bitdefender.net and its subdomains)
  • Verifying the signed firmware
  • Creating new partitions for storing the new "production" firmware
  • Rebooting into the new environment (production partition)

Note that the bootstrap partition remains untouched during this process, although it is technically possible to update it.

In this advisory, we focus on the first step of the update, that is right after factory reset, before the smartphone connects to the box. At this stage an attacker can connect to the "Bitdefender BOX" SSID, and interact via HTTP with the Lua scripts in the basic_ws directory, from version 2.0.1.91.

We identified an issue in the update_setup logic in basic_ws/handler.lua, reachable by requesting /api/update_setup. Note that while this issue is present in an old version of the firmware, it is possible to trigger it again after a factory reset, since the bootstrap partition is normally not updated. Also note that it is possible to communicate with uhttpd without authentication while in the bootstrap stage.

     function update_setup()
         local result = ""
         local new_setup_type = ""
 [6]     local code = os.execute("/opt/bitdefender/share/scripts/install_full_ws")
         if code == 0 then
             result = "ok"
             new_setup_type = "full"
         else
             result = read_cmd_result()
             new_setup_type = "basic"
         end

         write_state(build_state(new_setup_type, false, "update_setup", result))
     end

     local function update_setup_async(env)
         local max_size = 100 * 2^20 -- 100 MiB
         local payload = ""

         if tonumber(env.CONTENT_LENGTH) > max_size then
             payload = "{\"error\": \"error_max_upload_size_exceeded\"}\n"
             send_400(payload)
         else
 [4]        local download_err = uhttpd.recv_file("/tmp/full_ws.tar.gz", env.CONTENT_LENGTH)

             if download_err == 0 then
                 payload = "{\"uploaded\": true}\n"
                 send_200(payload)

                 write_state(build_state("basic", true, "update_setup", ""))

 [5]             async(update_setup)
             else
                 payload = "{\"uploaded\": false}\n"
                 send_400(payload)
             end
         end
     end

     ...

 [1] local function handle_post_request(env)
 [2]     local cmd_lock_rc = uhttpd.cmd_lock()

         if cmd_lock_rc ~= 0 then
             local payload = "{\"error\": \"error_command_already_running\"}\n"
             send_400(payload)
             return
         end

         if env.PATH_INFO == "api/update_setup" then
 [3]         update_setup_async(env)
         elseif env.PATH_INFO == "api/enable_ssh" then
             toggle_ssh(env, "on")
         elseif env.PATH_INFO == "api/disable_ssh" then
             toggle_ssh(env, "off")
         else
             local payload = "{\"error\": \"error_unknown_request\"}\n"
             send_400(payload)
         end

 [7]     uhttpd.cmd_unlock()
     end

Every time a POST request is performed [1], a lock (uhttpd.cmd_lock() [2]) is used to make sure the requests are performed atomically. When requesting /api/update_setup [3], the function update_setup_async is called. This method expects a signed full_ws.tar.gz file sent as data in the POST request, and saves it to /tmp/full_ws.tar.gz [4]. After that, the function update_setup is called using the function async [5], which forks the execution, making update_setup run in a different process. After the async call, update_setup_async returns immediately and the lock is removed [7].
Inside the forked process that runs the update_setup function, the script install_full_ws is executed. Since this happens in a parallel process, and since the lock may have already been released, a different /api/update_setup request can already be executed at this stage, leaving room for a race condition. Below is the code for the install_full_ws script:

      #!/bin/bash

      source /opt/bitdefender/share/scripts/common.sh
      source /opt/bitdefender/share/scripts/lib/image.sh

      SETUP_IMAGE="/tmp/full_ws.tar.gz"
      SETUP_PREINST="/opt/bitdefender/share/scripts/full_ws/preinst"
      SETUP_POSTINST="/opt/bitdefender/share/scripts/full_ws/postinst"

      if [ ! "$1" = "doit" ]; then
          #"${0}" doit > "/opt/bitdefender/var/log/$(basename "${0}").log" 2>& 1
          "${0}" doit 1>&2
          exit $?
      fi

      set -e
      set -x
 [8]  image_validate "${SETUP_IMAGE}"
      TMPDIR=$(mktemp -d)
      IS_ENCRYPTED=$(image_encrypted "${SETUP_IMAGE}")
 [9]  image_unpack "tar.gz" "${SETUP_IMAGE}" "${TMPDIR}" "${IS_ENCRYPTED}"

      if [ -x "${TMPDIR}${SETUP_PREINST}" ]; then
          "${TMPDIR}${SETUP_PREINST}"
      fi

 [9]  echo cp -r "${TMPDIR}"/* /
      rm -rf "${TMPDIR}"

      if [ -x "${SETUP_POSTINST}" ]; then
 [10]     "${SETUP_POSTINST}"
      fi

At [8], the script checks the image signature: if this fails, the script will exit because of set -e. Otherwise, the image will be unpacked in / [9] and the postinst file (that may present in the archive itself) will be executed [10]. This last step is used to make the full_ws archive to execute some additional steps after it has been unpacked.
Below is the code for the image_validate and image_unpack functions:

     function image_validate {
         local IMG="$1"

         get_platform_key

[11]     local EXPECTED_CHECKSUM=$(tar xzOf "$IMG" ./checksum)
         echo after checksum
[12]     local COMPUTED_CHECKSUM=$(tar xzOf "$IMG" ./img | openssl dgst -sha256 | cut -d' ' -f2)
         if [ ! "$EXPECTED_CHECKSUM" = "$COMPUTED_CHECKSUM" ]; then
             echo "Invalid image: checksum mismatch"
             echo -n "error_checksum_mismatch" > $WS_CMD_RESULT_FILE
             return 1
         fi

[13]     local SIG_CHECKSUM=$(tar xzOf "$IMG" ./sig | openssl rsautl -verify -pubin -inkey "${PLATFORM_KEY}" -keyform PEM)
         if [ ! "$EXPECTED_CHECKSUM" = "$SIG_CHECKSUM" ]; then
             echo "Invalid image: failed to authenticate signature"
             echo -n "error_signature_mismatch" > $WS_CMD_RESULT_FILE
             return 1
         fi

         echo "$IMG: image validated"
         return 0
     }

     ...

     function image_unpack {
         local TYPE="$1"
         local IMG="$2"
         local DST="$3"
         local ENC="$4"

[14]     local cmd=(tar xzOf "$IMG" ./img \|)
         if [ "$ENC" -eq "1" ]; then
             [ -e "/dev/fd" ] || ln -sf /proc/self/fd /dev/fd
             cmd+=(openssl enc -aes-256-cbc -d -pass "file:<(tar xzOf \"$IMG\" ./enc | openssl rsautl -verify -pubin -inkey \"${PLATFORM_KEY}\" -keyform PEM)" \|)
         fi

         case "$TYPE" in
             tar)
                 cmd+=(tar xf - -C "$DST")
             ;;
             tar.gz)
                 cmd+=(tar xzf - -C "$DST")
             ;;
             *)
                 echo "Unknown image type $TYPE"
                 echo -n "error_unknown_image_format" > $WS_CMD_RESULT_FILE
                 return 1
             ;;
         esac
[15]     eval "${cmd[*]}" || {
             echo -n "error_failed_to_decompress" > $WS_CMD_RESULT_FILE
             return 1
         }
         sync || true
     }

The full_ws.tar.gz image is expected to contain four files:

  • img: the contents to be extracted in /
  • checksum: the checksum of img
  • sig: the signature of the checksum
  • enc: existing only if encryption is enabled for img (optional)

At [11] the checksum is extracted from the image, and it's checked to be equal to the checksum of img [12]. The signature sig is then encrypted (RSA verify) using a public key common to all devices (PLATFORM_KEY) and the result is checked to be equal to the checksum. This is to make sure that only the entity that owns the RSA private key (i.e. the Bitdefender company) is able to create signed full_ws archives.

If the signature is verified, the function image_unpack is called and img is extracted, see [14] and [15].

Because, as explained before, this signature verification procedure doesn't necessarily happen within the locking window, an attacker can make the tar extraction at [12] to take a longer time than expected: for example, it's possible to take a genuine full_ws.tar.gz file and replace the original enc file with a 200MB zero-filled one.
This will not corrupt the signature checks, and will make sure that the image_validate function will still be running after the lock has been released at [7].

At the same time, the attacker can supply its custom full_ws.tar.gz file, which will replace the one in /tmp. If the timing is right, the first request will execute image_unpack after the genuine archive is replaced with the fake one, extracting the attacker's archive to /, and eventually executing any preinst script contained within.

Proof of Concept

The following proof-of-concept shows how to exploit the race condition and execute an arbitrary preinst script as root.

  1. Factory-reset the device and connect to the "Bitdefender BOX" SSID

  2. Take a signed full_ws.tar.gz archive from the smartphone app

  3. Inside the archive, replace enc with a 200MB zero-filled file

  4. Make a new archive fake.tar.gz containing a custom preinst script

  5. Optionally, it's possible to include the original sig file, to make sure the race condition is still working in case the window is too narrow (hitting the line at [13])

  6. Upload the two images in parallel, using the correct timing (seven seconds works fine for a 200MB enc file):

    $ curl -k -H "Content-Type: application/binary" --data-binary @files/fullws.tar.gz https://172.24.215.24/api/updatesetup; \ sleep 7; \ curl -k -H "Content-Type: application/binary" --data-binary @files/fake.tar.gz https://172.24.215.24/api/update_setup

Timeline

2019-10-31 - Vendor Disclosure 2019-01-21 - Public Release

Credit

Discovered by Claudio Bozzato, Dave McDaniel and Lilith Wyatt of Cisco Talos.