openssl / openssl/openssl

ChaCha20-Poly1305 zero-length EVP_Cipher() call can collapse distinct AAD inputs

Open
#32,752 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

branch: 3.4 branch: 3.5 branch: 3.6 branch: 4.0 branch: 4.1 branch: master help wanted triaged: bug
Dominant language
C
Stars
30.8k
Forks
11.5k
Avg merge
10m
Merged PRs (30d)
1

Description

I ran into this while reviewing PR #32011. An EVP_Cipher(ctx, out, in, 0) call made between two AAD fragments (real in and out pointers, zero input length) isn't transparent. It enters the payload branch of chacha20_poly1305_cipher, pads the AAD fragment seen so far and clears ctx->aad, but it leaves ctx->len.text at 0. The guard that's meant to reject late AAD only tests ctx->len.text != 0, so a further AAD update is still accepted.

The upshot is that distinct AAD inputs can collapse into the same Poly1305 transcript. The attached reproducer uses the same key and nonce throughout, with one fixed plaintext byte. Two different AAD strings of 18 bytes produce the same ciphertext and the same tag, and the tag produced for one AAD verifies successfully against the other AAD when the verifier repeats the same call sequence.

Worth stressing: the empty call isn't finalization. Both pointers are set and inl == 0. Finalization still goes through the normal EVP_EncryptFinal_ex() and EVP_DecryptFinal_ex().

The sequence

  1. Init a ChaCha20-Poly1305 context, for encryption or decryption.
  2. Feed the first AAD fragment with EVP_CipherUpdate(ctx, NULL, &outl, aad, split).
  3. Call EVP_Cipher(ctx, scratch, &dummy, 0): valid pointers, zero input length.
  4. Feed the rest of the AAD with EVP_CipherUpdate(ctx, NULL, &outl, aad + split, rest).
  5. Process one byte of payload and finish the operation normally.

The reproducer uses these two AAD strings, 18 bytes each. A is split after the first byte, B after the first 17:

A = 61 62 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
B = 61 00 00 00 00 00 00 00 00 00 00 00 00 00 00 62 00

The empty call pads the first fragment right away, but as far as the late AAD guard is concerned the payload phase hasn't started. When real payload finally arrives, the padding that closes out the second fragment is computed from the total AAD length. So both AAD strings push the exact same bytes into Poly1305 ahead of the ciphertext:

61 || 00^15 || 62 || 00^30

and the encoded AAD length at the end is 18 either way.

RFC 8439 section 2.8 frames the construction as the complete AAD, then its padding, then the ciphertext and its padding, then the encoded lengths:

https://www.rfc-editor.org/rfc/rfc8439.html#section-2.8

What I see here is the AAD getting an extra padded split in the middle while its total length is preserved, which is what lets two AAD strings that differ for the caller authenticate identically.

Observed output

On a master:

OpenSSL version: OpenSSL 4.1.0-dev
empty_call=0 ciphertext_equal=1 tag_equal=0 cross_verify=0
empty_call=1 ciphertext_equal=1 tag_equal=1 cross_verify=1
result=reproduced

Without the empty call, the ciphertexts match as expected since both runs share the same key and nonce and encrypt the same byte, but the tags differ and cross verification fails. With the empty call in place, the tags are equal and cross verification passes. Same story with OPENSSL_ia32cap=0, so this isn't tied to the accelerated code paths.

Branches tested

Everything below reproduces it, in default runs and with OPENSSL_ia32cap=0 alike:

Branch Revision and build
master ChaCha source matched origin/master 223e04f993f0df5c81358715eb7ed243ac31b1a6, OpenSSL 4.1.0-dev
openssl-4.0 27f097286cfa72354921610d9174724021aed97a, OpenSSL 4.0.3-dev
openssl-3.6 3a9d737c152c59fddb2d04e3eb7a343121f963ec, OpenSSL 3.6.5-dev
openssl-3.5 8a08b3ab7e3714848fc1b6eaaf14a8e716c2ecba, OpenSSL 3.5.9-dev
openssl-3.4 276d2ce9bd004d211eb45f3564889434f7484518, OpenSSL 3.4.8-dev

Related issues

This sits next to a few existing reports without duplicating them:

  • #32741: my report from yesterday, also out of the review of #32011. It covers the same kind of empty EVP_Cipher() call in the GCM and OCB providers. The GCM case is the mirror image of this one: there the empty call flushes the pending AAD fragment and the tag changes even though the logical AAD didn't. Here the rest of the AAD still goes through after the empty call, so two different logical AAD strings end up authenticating the same.
  • #31188: that one concerns AAD fed after a payload update that carried actual data. My sequence uses an empty payload update, so ctx->len.text stays 0 and the late AAD guard never fires.
  • #32258: that one relies on finalizing an empty ciphertext. Mine doesn't; the reproducer moves one byte of real data and finalizes the usual way.
  • #31285: that one is about decryption succeeding with no tag at all. Here a full 16 byte tag is set and checked.

Reproducer

Attached as CHACHA_COLLISION_REPRODUCER.c inside CHACHA_COLLISION_REPRODUCER.zip. Build it from a compiled OpenSSL tree:

cc -std=c11 -Wall -Wextra -Werror -O2 -Iinclude CHACHA_COLLISION_REPRODUCER.c \
    -L. -lcrypto -o chacha-collision
./util/wrap.pl ./chacha-collision
OPENSSL_ia32cap=0 ./util/wrap.pl ./chacha-collision

I will open a PR to address this.

CHACHA_COLLISION_REPRODUCER.zip

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Build and run CHACHA_COLLISION_REPRODUCER.c with the commands in the issue, including the OPENSSL_ia32cap=0 variant, to confirm the collision. Then inspect chacha20_poly1305_cipher and the AAD/payload transition around the zero-length EVP_Cipher() call. Done means distinct AAD inputs no longer produce equal tags or cross-verify successfully, while normal finalization still works.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
cryptography, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.