//nefariousplan

CVE-2026-0073: adbd's EVP_PKEY_cmp Was the 2020 OpenIKED Bug

pattern

cve

proof of concept

if (EVP_PKEY_cmp(known_evp.get(), evp_pkey.get())) is the gate daemon/auth.cpp uses to authenticate wireless ADB clients. EVP_PKEY_cmp returns 1, 0, -1, or -2. Three of those four values are truthy in C++. Two of them mean no.

CVE-2026-0073 is what falls out when an attacker on the same Wi-Fi network presents a TLS client certificate whose key type is not RSA. EC P-256 makes EVP_PKEY_cmp return -1. Ed25519 makes it return -2. Either is truthy. Either gets a uid 2000 shell.

OpenIKED filed CVE-2020-16088 for the same misuse of the same function in 2020.

The store is RSA-only. The cmp accepts whatever the attacker sent.

adbd_tls_verify_cert lives in packages/modules/adb/daemon/auth.cpp. BoringSSL invokes it during TLS 1.3 client-certificate verification on the wireless-debugging path. Its job is to compare the public key the client presented against the public keys stored in /data/misc/adb/adb_keys, the file the "Allow USB debugging" prompt and adb pair write into when a user authorizes a workstation.

int adbd_tls_verify_cert(X509_STORE_CTX* ctx, std::string* auth_key) {
  if (!auth_required) {
    return 1;
  }

  bool authorized = false;
  X509* cert = X509_STORE_CTX_get0_cert(ctx);
  if (cert == nullptr) return 0;

  bssl::UniquePtr<EVP_PKEY> evp_pkey(X509_get_pubkey(cert));
  if (evp_pkey == nullptr) return 0;

  IteratePublicKeys([&](std::string_view public_key) {
    /* ... b64_pton into keybuf ... */
    RSA* key = nullptr;
    if (!android_pubkey_decode(keybuf, ANDROID_PUBKEY_ENCODED_SIZE, &key)) {
      return true;
    }

    bool verified = false;
    bssl::UniquePtr<EVP_PKEY> known_evp(EVP_PKEY_new());
    EVP_PKEY_set1_RSA(known_evp.get(), key);
    if (EVP_PKEY_cmp(known_evp.get(), evp_pkey.get())) {
      VLOG(AUTH) << "Matched auth_key=" << public_key;
      verified = true;
    } else {
      VLOG(AUTH) << "auth_key doesn't match [" << public_key << "]";
    }
    RSA_free(key);
    if (verified) {
      *auth_key = public_key;
      authorized = true;
      return false;
    }
    return true;
  });

  return authorized ? 1 : 0;
}

known_evp is constructed inside the loop body. EVP_PKEY_set1_RSA makes it an RSA-typed EVP_PKEY, and android_pubkey_decode is the only callback that produces the RSA* it consumes; that decoder reads the Android-specific RSA public-key format and only that format. Every key in adb_keys is RSA. known_evp is always RSA.

evp_pkey is the public key BoringSSL parsed out of the X.509 certificate the client presented. The function imposes no constraint on its key type. X509_get_pubkey returns whatever's in the cert: RSA of any modulus, EC over any supported curve, Ed25519, X25519.

The comparison is an always-RSA stored key against an attacker-controlled cert key. The asymmetry is the bug.

Three of EVP_PKEY_cmp's four return values are truthy in C++. Two of them mean no.

EVP_PKEY_cmp is a BoringSSL function inherited from OpenSSL with the documented return semantics:

Return Meaning
1 Keys match
0 Keys differ
-1 Key types are different
-2 Operation not supported

Wrapped in if (EVP_PKEY_cmp(...)), the C++ truthiness of the return value drives the branch. 1, -1, and -2 are all truthy. Only 0 is falsy. Three of four return values land on the match branch. Two of those three returns mean EVP_PKEY_cmp could not perform the comparison.

OpenSSL added EVP_PKEY_eq to OpenSSL 3.0 in September 2021 specifically because of this class of bug. The man page for EVP_PKEY_cmp since OpenSSL 3.0 contains one sentence: "The use of EVP_PKEY_cmp() is discouraged. Use EVP_PKEY_eq() instead." EVP_PKEY_eq returns only 0 or 1. There is no truthy-mismatch case to fall through.

The patch Google shipped for CVE-2026-0073 did not migrate to EVP_PKEY_eq. It added == 1.

The wire chain

The wireless-debugging path on Android 11+ opens an adbd listener on a randomized port (typically in the 30000 to 50000 range) advertised over mDNS. The handshake before TLS is cleartext; TLS upgrade is signaled by an STLS packet exchange. adb tcpip 5555 is a different code path that uses the legacy AUTH challenge and is not affected by this CVE.

Attacker                              Target adbd

  TCP connect ----------------------->
  CNXN(version, banner) ------------->
                  <----------------- (CNXN, on some builds)
                  <----------------- STLS(version)
  STLS(version) --------------------->

  ====== TLS 1.3 ClientHello, EC P-256 client cert =====>
                                       BoringSSL invokes adbd_tls_verify_cert
                                       EVP_PKEY_cmp(RSA, EC) returns -1
                                       if (-1) is true; verified = true
                                       authorized = true; cert verifies
  ====== TLS 1.3 Finished ==============>

                  <----------------- CNXN(device info, post-TLS)
  OPEN(local_id, window=0x2000000, "shell:\x00") ->
                  <----------------- OKAY(remote_id)
                  <----------------- WRTE(stdout)

The TLS client cert is generated on demand. For the EC path, an EC P-256 key plus a self-signed certificate is sufficient:

key = ec.generate_private_key(ec.SECP256R1())
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "adbkey")])
cert = (x509.CertificateBuilder()
    .subject_name(subject).issuer_name(subject)
    .public_key(key.public_key())
    .serial_number(x509.random_serial_number())
    .not_valid_before(datetime.datetime.utcnow())
    .not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=1))
    .sign(key, hashes.SHA256()))

That cert reaches adbd_tls_verify_cert with evp_pkey typed EC. The first iteration of IteratePublicKeys constructs known_evp typed RSA. EVP_PKEY_cmp(RSA, EC) returns -1. if (-1) is true. verified flips to true. The callback returns false, terminating iteration. authorized propagates back as 1. BoringSSL is told the cert verified. adbd_wifi_secure_connect brings the transport online. The next packet on the wire is the device's CNXN announcing itself, and an OPEN of shell:\0 produces a uid 2000 shell.

The precondition is one stored RSA key in adb_keys. If the user has never accepted the "Allow USB debugging" dialog on this device, the loop has nothing to iterate and the function returns 0. Wireless pairing alone (adb pair) writes into a different file, adb_known_hosts.pb, which is read by a different code path. The bypass needs the device to have been USB-paired with at least one workstation at any point in its history. For most developer phones, that condition has been satisfied for years.

EC returns -1. Ed25519 returns -2. Both are truthy.

The PoCs that surfaced in the seventy-two hours after the May 2026 Android Security Bulletin diverge on which key type they use. The earliest, SecTestAnnaQuinn/CVE-2026-0073, uses EC P-256 only. MartinPSDev and adityatelange add an Ed25519 fallback. adityatelange's file states the math out loud:

# Key types that trigger non-zero (non-match) returns from EVP_PKEY_cmp:
#   ec       -> EVP_PKEY_cmp returns -1 (different key type)
#   ed25519  -> EVP_PKEY_cmp returns -2 (operation not supported by BoringSSL)
# Both are non-zero and therefore truthy in the buggy if-condition.

Two distinct OpenSSL/BoringSSL return paths reach the match branch. If Google's patch had handled only the type-mismatch case, say if (cmp != 0 && cmp != -1), the Ed25519 path would still bypass. The patch covers both because it tests for == 1, the only return value EVP_PKEY_cmp is documented to use for "keys are equal."

OpenIKED shipped this bug in 2020. The fix is one comparison apart.

CVE-2020-16088 affects every version of OpenIKED in OpenBSD through 6.7. The vulnerable file is iked/ca.c. The vulnerable check is:

if (!EVP_PKEY_cmp(peerkey, localkey))
    /* peer key does not match the configured local key */

The patch OpenBSD shipped in 6.7-stable in 2020:

- if (!EVP_PKEY_cmp(peerkey, localkey))
+ if (EVP_PKEY_cmp(peerkey, localkey) != 1)

CVE-2026-0073 affects every version of Android adbd through Android 16 QPR2. The vulnerable file is packages/modules/adb/daemon/auth.cpp. The vulnerable check is:

if (EVP_PKEY_cmp(known_evp.get(), evp_pkey.get()))
    /* keys match */

The patch Google shipped in commit 842d331f under the Change-Id I20f3abf93ab9bd0bd349d17cce2ba7f56fa88e36, authored 9 January 2026 by Joshua Duong, landed on AOSP main on 5 March 2026 with the subject "Fix EVP_PKEY_cmp usage. Not all non-zero values mean success.":

- if (EVP_PKEY_cmp(known_evp.get(), evp_pkey.get())) {
-     VLOG(AUTH) << "Matched auth_key=" << public_key;
+ int cmp_result = EVP_PKEY_cmp(known_evp.get(), evp_pkey.get());
+ if (cmp_result == 1) {
+     VLOG(AUTH) << "Matched auth_key=" << public_key;

The two diffs sit on opposite polarities of the same comparison. OpenIKED's bug treated -1 as "not mismatch" (!cmp is false when cmp is -1, so the rejection path was skipped). adbd's bug treated -1 as "match" (cmp is true when cmp is -1, so the success path ran). The function being misused is the same function. The misuse is the same misuse. The CVEs are six years apart.

This is the design-debt-driver shape with the substrate sitting one layer below the codebase. EVP_PKEY_cmp is the substrate. Each consumer that wraps it in C truthiness produces an authentication bypass of the same shape. OpenSSL's response in 2021 was to ship EVP_PKEY_eq and label EVP_PKEY_cmp discouraged. BoringSSL has not added EVP_PKEY_eq. AOSP's March 2026 fix is the smallest possible change at the call site: a literal comparison to 1 on the same line. The substrate is unchanged. The next CVE in this family is the next AOSP, BoringSSL-consuming, or vendor-fork code path that calls EVP_PKEY_cmp and treats the return as a bool.

adbd logged that the keys matched.

The pre-patch log statement on the truthy branch is VLOG(AUTH) << "Matched auth_key=" << public_key. verified is set true. public_key is whatever entry the iteration was up to in /data/misc/adb/adb_keys, an actual key the device's user authorized at some past pairing.

When an attacker bypasses with EC P-256, EVP_PKEY_cmp returns -1, verified becomes true on the first iteration, and the device's logcat records Matched auth_key=<the public key the user authorized last year>. A defender reading the logs sees the same line they would see for a legitimate connection from the workstation that key belongs to. There is no log entry that says "type mismatch." There is no log entry that says the comparison did not run. The log says the keys matched.

The patch changes the else branch to log cmp_result directly. Defenders of patched devices can at least see the -1 and -2 returns happening on the wire when an attacker probes. Defenders of unpatched devices have logs that lie.

PoC: SecTestAnnaQuinn/CVE-2026-0073-Android-adbd-authentication-bypass-POC

OpenIKED filed CVE-2020-16088 for this misuse of EVP_PKEY_cmp in 2020. adbd filed CVE-2026-0073 for the same misuse, six years later.