CVE-2019-17102
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.
Bitdefender BOX 2, version 2.0.1.91
https://www.bitdefender.com/box/
9.0 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H
CWE-413: Improper Resource Locking
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:
/api/update_setup.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.The setup process continues using the new scripts, the next steps involve, in broad terms:
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:
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.
The following proof-of-concept shows how to exploit the race condition and execute an arbitrary preinst script as root.
Factory-reset the device and connect to the “Bitdefender BOX” SSID
Take a signed full_ws.tar.gz archive from the smartphone app
Inside the archive, replace enc with a 200MB zero-filled file
Make a new archive fake.tar.gz containing a custom preinst script
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])
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/full_ws.tar.gz https://172.24.215.24/api/update_setup; \ sleep 7; \ curl -k -H “Content-Type: application/binary” –data-binary @files/fake.tar.gz https://172.24.215.24/api/update_setup
2019-10-31 - Vendor Disclosure 2019-01-21 - Public Release
Discovered by Claudio Bozzato, Dave McDaniel and Lilith Wyatt of Cisco Talos.