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.