ANT-2026-BT210RQ0 · libtom/libtomcrypt
other critical
Severity Claude critical · Security research firm - · Maintainer -
Anthropic's analysis of this finding, sealed at approval.
ANT-2026-BT210RQ0: libtomcrypt AES-SIV: universal forgery and plaintext recovery when called without AD
In src/encauth/siv/siv.c, s_siv_S2V() (and the equivalent check in siv_memory()) treats the caller supplying zero AD components — or an empty first AD string — as the RFC 5297 n=0 case and returns CMAC(K1, ) without reading the plaintext. In SIV the plaintext is always the last S2V input, so n is at least 1 and that branch should be unreachable. The resulting V is a per-key constant, so every message under a key gets the same 16-byte authentication tag and the same CTR keystream. An attacker who observes a single ciphertext can therefore forge arbitrary ciphertexts that decrypt successfully (V || anything passes the tag check) and recover every other plaintext via the standard fixed-keystream XOR attack, including choosing the plaintext their forgery decrypts to.
Target
Project: libtom/libtomcrypt
Version: develop branch (post-1.18.2); not present in tagged release 1.18.2
Location: src/encauth/siv/siv.c:162-198 (s_siv_S2V); siv.c:455-458 (siv_memory)
Technical Details
The guard if (ad == NULL || adlen == NULL || ad[0] == NULL || adlen[0] == 0) maps "no AD" to S2V's n=0 special case and calls s_siv_S2V_one(), which returns CMAC(K1, ) and ignores in/inlen (the plaintext). RFC 5297's S2V is invoked from SIV with the plaintext as the final string, so n = m+1 ≥ 1 and the n=0 branch must never be taken from SIV-Encrypt/Decrypt. Because V is both the authentication tag and (after masking two bits) the CTR initial counter, a constant V simultaneously destroys integrity (recomputed tag on decrypt is the same constant, so any V || body authenticates) and confidentiality (fixed keystream ⇒ C1 XOR C2 = P1 XOR P2).
Reproduction
- Capture any ciphertext V || C produced under key K with no AD.
- Extract the first 16 bytes as V (constant for K).
- Recover keystream KS = C XOR known/guessed plaintext (or XOR two ciphertext bodies to get P1 XOR P2).
- Forge V || (desired_plaintext XOR KS); receiver recomputes the same constant V and accepts.
- XOR any other captured ciphertext body with KS to recover its plaintext.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
On the no-AD path, treat the input as the n=1 case: initialise D = CMAC(K1, ) and call s_siv_S2V_T over (in, inlen) so the plaintext is folded into V. Apply the same change in siv_memory(). Note this exposes an in-place-buffer aliasing bug in siv_test()'s 1000-iteration round-trip loop (siv.c:706-732), which only passed before because V was constant; the test needs updating.
Acknowledgement
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-BT210RQ0.
Reference: ANT-2026-BT210RQ0
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
https://github.com/libtom/libtomcrypt/pull/726
Dates from discovery through public reveal.
- 2026-04-16 Sent to maintainer
- 2026-04-16 Maintainer acknowledged
- 2026-04-21 Patch released
- 2026-05-14 Reported to tracker
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
935c74569ba99b976e3042c6fb78c9ff04ddc71015876f6bac6142ce9568019c5db725d6251c1765a2144f80a2cd967f1bfd457b428475a6cf9a3fce24f86087
Committed 2026-04-17 11:43 PT
Revealed 2026-08-17 17:36 PT
Verify (download preimage.json)
Show preimage JSON
{
"ant_id": "ANT-2026-BT210RQ0",
"bug_class": "Cryptographic Logic Flaw",
"claude_severity": "critical",
"commit_sha": null,
"created_at": "2026-05-14T22:42:40+00:00",
"description": "In src/encauth/siv/siv.c, s_siv_S2V() (and the equivalent check in siv_memory()) treats the caller supplying zero AD components — or an empty first AD string — as the RFC 5297 n=0 case and returns CMAC(K1, <one>) without reading the plaintext. In SIV the plaintext is always the last S2V input, so n is at least 1 and that branch should be unreachable. The resulting V is a per-key constant, so every message under a key gets the same 16-byte authentication tag and the same CTR keystream. An attacker who observes a single ciphertext can therefore forge arbitrary ciphertexts that decrypt successfully (V || anything passes the tag check) and recover every other plaintext via the standard fixed-keystream XOR attack, including choosing the plaintext their forgery decrypts to.",
"discovered_at": "2026-04-17T18:43:45+00:00",
"location": "src/encauth/siv/siv.c:162-198 (s_siv_S2V); siv.c:455-458 (siv_memory)",
"poc_sha256": null,
"preimage_version": 1,
"project": "libtom/libtomcrypt",
"reproduction": [
"1. Capture any ciphertext V || C produced under key K with no AD.",
"2. Extract the first 16 bytes as V (constant for K).",
"3. Recover keystream KS = C XOR known/guessed plaintext (or XOR two ciphertext bodies to get P1 XOR P2).",
"4. Forge V || (desired_plaintext XOR KS); receiver recomputes the same constant V and accepts.",
"5. XOR any other captured ciphertext body with KS to recover its plaintext."
],
"technical_details": "Hi --\n\nI've found a logic bug in libtomcrypt's AES-SIV implementation.\n\nAES-SIV is an authenticated-encryption mode that's supposed to be safe\n\nto use without a nonce: the authentication tag is derived from the\n\nplaintext itself, so distinct messages can't collide. In libtomcrypt,\n\nwhen the caller supplies no \"associated data\" (which is the normal way\n\nto use SIV for things like wrapping keys) the plaintext is never mixed\n\ninto the tag at all, and every message under a given key gets the same\n\n16-byte tag and is encrypted with the same keystream. Observing a\n\nsingle ciphertext is therefore enough to forge arbitrary new\n\nciphertexts that decrypt successfully, and to recover every other\n\nplaintext encrypted under that key. The attached C program encrypts\n\ntwo different messages, shows the tags are identical, and shows a\n\nforged ciphertext acceptance.\n\n(Up-front disclosure: an LLM found this bug. I validated the bug myself,\n\nand have confirmed the PoC works to the best of my understanding.\n\nI stand by this report myself personally and vouch for its correctness.)\n\nThis is one of eleven libtomcrypt findings from the same scan. The\n\nothers are less severe. I could either send them over all to you right\n\naway without manual validation, or if you'd prefer we can have them\n\nvalidated first but this will take a bit more time. If you can let me\n\nknow what you'd prefer I'll go with whatever is better for you.\n\nOnto the bug.\n\nSIV (RFC 5297) builds an authenticated-encryption mode out of two\n\nstandard AES pieces: CMAC (a keyed MAC over arbitrary-length input that\n\noutputs a 16-byte tag) and CTR mode (which turns AES into a stream\n\ncipher by encrypting an incrementing counter and XORing the result with\n\nthe plaintext). SIV takes a double-length key (32 bytes for\n\nAES-128-SIV, 48 for AES-192, 64 for AES-256) and splits it in half:\n\nthe first half K1 keys the CMAC, the second half K2 keys the CTR.\n\nEncryption takes the plaintext plus zero or more \"associated data\" (AD)\n\nstrings: headers, nonces, whatever else the caller wants bound to the\n\nciphertext. It first computes a 16-byte \"synthetic IV\" called V by\n\nrunning a function called S2V over K1, all the AD strings, and the\n\nplaintext:\n\n V = S2V(K1, AD_1, ..., AD_m, plaintext)\n\nThen it encrypts the plaintext with AES-CTR keyed by K2, using V (with\n\ntwo bits masked off) as the initial counter, and outputs V prepended\n\nto the ciphertext. V doubles as the authentication tag and the\n\nIV. Because the plaintext is one of the inputs to S2V, distinct\n\nplaintexts produce distinct V values, and so SIV safe even when the\n\ncaller doesn't supply a nonce.\n\nS2V (RFC 5297 sec 2.4) is defined as a function over a vector of n\n\nstrings. Roughly: it starts from CMAC(K, <zero>), folds each string in\n\nwith a doubling-and-XOR step, and finishes with a CMAC over the last\n\nstring combined with the running value. The spec gives it a special\n\ncase at the top: \"if n = 0, return CMAC(K, <one>)\", where <one> is the\n\n16-byte encoding of the integer 1.\n\nInside SIV, that the caller of S2V always passes the plaintext as the\n\nlast string. So n = m + 1, which is at least 1 even when there are\n\nzero AD components. Thus the n = 0 branch is unreachable from\n\nSIV-Encrypt or SIV-Decrypt.\n\nlibtomcrypt's s_siv_S2V() (src/encauth/siv/siv.c:162-198) maps \"caller\n\nsupplied no AD components\" to \"n = 0\" and takes the short-circuit:\n\n if(ad == NULL || adlen == NULL || ad[0] == NULL || adlen[0] == 0) {\n\n err = s_siv_S2V_one(cipher, key, keylen, V, Vlen);\n\n } else {\n\n ...\n\n err = s_siv_S2V_T(&ctx, in, inlen, D, V, Vlen);\n\n }\n\ns_siv_S2V_one returns CMAC(K1, <one>). The function arguments (the\n\nplaintext!) aren't read on this path. Thus, V is a per-key\n\nconstant. The same branch is taken if adlen[0] == 0, so a\n\npresent-but-empty first AD string also triggers it.\n\nThe varargs entry point siv_memory() has the identical test at\n\nsiv.c:455-458.\n\nWith the value V constant, both security properties of the AEAD fail\n\nat once.\n\nIntegrity: on decrypt, the receiver strips the first 16 bytes as V,\n\nCTR-decrypts the rest, recomputes S2V over the recovered plaintext,\n\nand compares. But \"recomputes S2V\" on the no-AD path means \"recomputes\n\nthe same per-key constant\", which matches the V the attacker copied\n\nfrom any observed ciphertext. This means V || anything returns\n\nCRYPT_OK, and so in one observation the attacker gets an unlimited\n\nnumber of forgeries.\n\nConfidentiality: the CTR initial counter is derived from V (two bits\n\nmasked), so it's also constant. CTR mode with a fixed counter produces\n\na fixed keystream, and CTR ciphertext is plaintext XOR keystream. The\n\nstandard trivial attack here leaks the plaintext: C1 XOR C2 = P1 XOR\n\nP2, and one known or guessable plaintext recovers the keystream\n\noutright. By combining the two, the attacker can choose the plaintext\n\ntheir forgery decrypts to, by sending V || (desired_plaintext XOR\n\nkeystream).\n\nI don't think that Zero-AD SIV is an edge case. It looks like the\n\nlibrary's own crypt.tex documentation (line 2591) says SIV \"does not\n\nenforce\" the nonce/AAD and \"leaves it up to the user\". siv_test() at\n\nsiv.c:706-732, for example, calls siv_memory with no AD in an encrypt\n\nloop and decrypt loop, and only checks that the result round-trips to\n\nthe original without comparing the tag. LTC_SIV_MODE --- the\n\npreprocessor define that controls whether SIV is compiled into the\n\nlibrary at all --- is enabled by default (tomcrypt_custom.h:311), so\n\nthere's no build-time gate.\n\nIt looks like SIV landed on the develop branch and is not in the last\n\ntagged release (1.18.2, 2018). But it seems that develop has been the\n\nde-facto upstream for several years and is what most downstream\n\npackaging tracks.\n\nThe fix, I think, is to make the no-AD path still fold the plaintext in\n\nas the n = 1 case does: initialise D = CMAC(K1, <zero>) and then call\n\ns_siv_S2V_T over (in, inlen). I've attached a patch that does this for\n\nboth s_siv_S2V and siv_memory.\n\nThe patch does break siv_test()'s 1000-iteration round-trip loop at\n\nsiv.c:706-732 though. That loop calls siv_memory with the same buffer\n\nas both input and output; on encrypt, siv_memory writes V to\n\nout[0..15] before CTR-reading in[0..15], so with in == out the first\n\n16 plaintext bytes are clobbered. This only round-tripped before\n\nbecause V was a constant (so clobber-then-encrypt,\n\ndecrypt-then-recompute-constant matched). With a correct V the\n\nrecomputed tag differs and the loop reports CRYPT_ERROR. I believe\n\nthe test, not the fix, is what needs to change there. But this is\n\ngetting a bit far outside of my knowledge, so happy to adjust if you'd\n\nrather handle it differently.\n\nThanks, and please let me know if you'd like to discuss anything,\n\nNicholas\n\nTo reproduce on a fresh Ubuntu 24.04 install:\n\n git clone https://github.com/libtom/libtommath.git\n\n git clone https://github.com/libtom/libtomcrypt.git\n\n ( cd libtommath && make -j )\n\n ( cd libtomcrypt && make -j \\\n\n CFLAGS=\"-DUSE_LTM -DLTM_DESC -I../libtommath\" \\\n\n EXTRALIBS=../libtommath/libtommath.a )\n\n cat > /tmp/sivpoc.c <<'EOF'\n\n #include <tomcrypt.h>\n\n #include <stdio.h>\n\n #include <string.h>\n\n static void hex(const char *lbl, const unsigned char *b, unsigned long n) {\n\n printf(\"%-18s\", lbl); for (unsigned long i=0;i<n;i++)\n\nprintf(\"%02x\", b[i]);\n\n printf(\"\\n\");\n\n }\n\n int main(void) {\n\n unsigned char key[32] = {0}; /* K1 || K2, zeros */\n\n unsigned char pt1[20], pt2[20];\n\n memset(pt1, 0xAA, sizeof pt1);\n\n memset(pt2, 0x55, sizeof pt2); /* pt2 = ~pt1 */\n\n unsigned char ct1[36], ct2[36], forged[36], out[20];\n\n unsigned long ct1len=sizeof ct1, ct2len=sizeof ct2, outlen=sizeof out;\n\n const unsigned char *ad[] = { NULL };\n\n unsigned long adl[] = { 0 };\n\n register_cipher(&aes_desc);\n\n int aes = find_cipher(\"aes\");\n\n siv_encrypt_memory(aes, key, 32, ad, adl, pt1, 20, ct1, &ct1len);\n\n siv_encrypt_memory(aes, key, 32, ad, adl, pt2, 20, ct2, &ct2len);\n\n hex(\"V(tag) of ct1:\", ct1, 16);\n\n hex(\"V(tag) of ct2:\", ct2, 16);\n\n printf(\"V1 == V2 ? %s\\n\", memcmp(ct1, ct2, 16)==0 ? \"YES (BUG)\":\"no\");\n\n /* keystream reuse: ct_body XOR pt = constant keystream */\n\n unsigned char ks[20];\n\n for (int i=0;i<20;i++) ks[i] = ct1[16+i] ^ pt1[i];\n\n /* forgery: V || (desired_pt XOR keystream), decrypts to desired_pt */\n\n memcpy(forged, ct1, 16);\n\n for (int i=0;i<20;i++) forged[16+i] = 0x42 ^ ks[i];\n\n int e = siv_decrypt_memory(aes, key, 32, ad, adl, forged, 36,\n\nout, &outlen);\n\n printf(\"decrypt forged -> err=%d (%s)\\n\", e, error_to_string(e));\n\n hex(\"decrypted pt:\", out, outlen);\n\n /* control: with AD, a tampered ciphertext is rejected */\n\n const unsigned char *ad2[] = { (const unsigned char*)\"hdr\", NULL };\n\n unsigned long adl2[] = { 3, 0 };\n\n unsigned char ctA[36]; unsigned long ctAlen=sizeof ctA;\n\n siv_encrypt_memory(aes, key, 32, ad2, adl2, pt1, 20, ctA, &ctAlen);\n\n ctA[20] ^= 1; outlen = sizeof out;\n\n e = siv_decrypt_memory(aes, key, 32, ad2, adl2, ctA, ctAlen,\n\nout, &outlen);\n\n printf(\"[CONTROL with-AD tampered] err=%d (%s)\\n\", e, error_to_string(e));\n\n return 0;\n\n }\n\n EOF\n\n cc /tmp/sivpoc.c -Ilibtomcrypt/src/headers \\\n\n libtomcrypt/libtomcrypt.a libtommath/libtommath.a -o /tmp/sivpoc\n\n /tmp/sivpoc\n\n # V(tag) of ct1: a3582dddfa01ffc4d94be62deb0972d8\n\n # V(tag) of ct2: a3582dddfa01ffc4d94be62deb0972d8\n\n # V1 == V2 ? YES (BUG)\n\n # decrypt forged -> err=0 (CRYPT_OK)\n\n # decrypted pt: 4242424242424242424242424242424242424242\n\n # [CONTROL with-AD tampered] err=1 (CRYPT_ERROR)",
"title": "libtomcrypt AES-SIV: universal forgery and plaintext recovery when called without AD",
"vendor_severity": null
}