Skip to content

8.9.0 ChaCha ARMv8 NEON backend produces 1-bit incorrect keystream for a specific state #1362

Description

@blackcruiser

Version: Crypto++ 8.9.0
Backend: ARMv8 (aarch64) NEON — chacha_simd.cpp, ChaCha_OperateKeystream_NEON
Toolchain: Android NDK r26.1.10909125 (clang 17) and r21.4.7075529 (clang 9), both -O3, -march=armv8-a
Target ABI: arm64-v8a
Confirmed reproducing devices: Sony Xperia 1 IV, Huawei Mate 60 Pro, Oppo Find X7, Xiaomi 11, Xiaomi 13 Pro

Summary

On aarch64, decrypting a certain 12 400-byte ChaCha12 ciphertext with the
stock Crypto++ 8.9.0 aarch64 NEON backend yields a plaintext that differs
from the reference decrypt (produced by the x86 AVX2 backend running the
same key / IV / rounds / offset on x64) by exactly one bit at byte
offset 11252:

  • x64 AVX2 build → Byte@11252 = 0xAD, PlainCRC32 = 10BEA605
  • arm64 NEON build → Byte@11252 = 0xAC, PlainCRC32 = A94976F4

Byte 11252 sits in stream block 175 (offset 52 inside the block, i.e.
word 13 of the ChaCha state). Every other byte of the 12 400-byte
plaintext is byte-identical between the two builds.

The bug reproduces with both NDK r26.1 (clang 17) and NDK r21.4
(clang 9)
, on five different physical devices covering four
different arm64 SoC vendors (Sony Xperia 1 IV / Snapdragon 8 Gen 1,
Huawei Mate 60 Pro / Kirin 9000S, Oppo Find X7 / Dimensity 9300,
Xiaomi 11 / Snapdragon 888, Xiaomi 13 Pro / Snapdragon 8 Gen 2). Same
key / IV / rounds / offset, same wrong bit on every combination. That
rules out a device-specific silicon issue and points at either the
Crypto++ source or the shared clang aarch64 backend.

The divergence is silent (no crash, no size change, just one flipped
bit in the emitted keystream), which means any caller decrypting more
than 3 * 64 = 192 bytes with the aarch64 NEON path can produce corrupted
plaintext without any error.

Reproducer project

A minimal cross-platform harness is available:

Layout:

ChaChaDecryptTest/
├── CryptoPP8.9.0/          # vendored, unmodified copy of Crypto++ 8.9.0
├── Share/                  # shared header with the hard-coded key/iv/rounds
│                           #   and probe offset used by both builds
├── VisualStudio/           # x64 harness (MSVC): calls CryptoPP::ChaCha::Decryption
│                           #   the normal way, prints ciphertext CRC / plaintext
│                           #   CRC / the byte at probe offset / a 128-byte
│                           #   Cipher/Plain/Stream window / a full hex dump of
│                           #   the decrypted buffer
└── AndroidStudio/          # arm64-v8a harness (NDK r26 / clang 17):
                            #   identical harness, plus JNI plumbing. Writes the
                            #   same set of lines to chacha_log.txt on device.

Both builds use identical:

  • Key (32 bytes, hard-coded in Share/CryptoConfig.h)
  • IV (8 bytes, same file)
  • Rounds (12, same file)
  • Probe offset (11252, same file)
  • Ciphertext file (encrypto.bin, 12 400 bytes; shipped with the repo)

Every diagnostic line printed by the two builds uses the same format, so
diff between the Windows console log and the Android chacha_log.txt
should be empty on a correct implementation.

Reproduction steps

  1. Clone the reproducer repo above.
  2. Open VisualStudio/ChaChaDecryptTest/ChaChaDecryptTest.sln, build x64 Release.
  3. Run it. Capture the console output — this is the known-good baseline.
  4. Open AndroidStudio/ in Android Studio, build the debug APK, install
    on any arm64-v8a Android device (one of the confirmed models above
    works; any other arm64 device likely also does).
  5. Launch the app, tap "Run test".
    The app writes its output to chacha_log.txt (also mirrored to
    logcat under tag ChaChaDecryptTest).
  6. Diff the two logs.

Observed output (unmodified upstream Crypto++ 8.9.0):

Windows / x64 / AVX2:
  Provider=AVX2
  CipherCRC32=E093AD5B DataSize=12400
  PlainCRC32=10BEA605
  Byte@11252=0xAD
  <full hex dump of the decrypted 12 400 bytes>

Android / arm64-v8a / NEON:
  Provider=NEON                              <-- differs from AVX2 (expected)
  CipherCRC32=E093AD5B DataSize=12400        <-- same input
  PlainCRC32=A94976F4                        <-- DIFFERS
  Byte@11252=0xAC                            <-- DIFFERS by 1 bit (0xAD vs 0xAC)
  <full hex dump; the single-byte difference is at offset 11252>

The reproducer is deterministic: same key / IV / rounds / offset, same
Byte@11252 = 0xAC on every arm64 run, on every device / NDK
combination we tried.

Where the bug lives

The affected function is ChaCha_OperateKeystream_NEON in
CryptoPP8.9.0/chacha_simd.cpp. It processes 4 blocks per call; byte
11252 lives in block 175, which lands in the "block 3" slot (the 4th
and last of the 4-block SIMD batch) of the batch that starts at counter
172.

Inside that function, the only lane 1 (word 13) is touched by a special
helper:

// chacha_simd.cpp (unmodified upstream)
inline uint32x4_t Add64(const uint32x4_t& a, const uint32x4_t& b)
{
    return vreinterpretq_u32_u64(
        vaddq_u64(
            vreinterpretq_u64_u32(a),
            vreinterpretq_u64_u32(b)));
}

Add64 is invoked six times inside ChaCha_OperateKeystream_NEON:

  • Three times before the round loop to seed r1_3 / r2_3 / r3_3
    with counter offsets +1 / +2 / +3 on top of the initial state3.
  • Three times after the round loop, on top of the per-block
    +state3 sum, to apply the same counter offsets to the finished
    block-1 / block-2 / block-3 output vectors.

All six call sites pass CTRS[k] as the second operand, where each
CTRS[k] is initialised from the constant array
w[] = {1,0,0,0, 2,0,0,0, 3,0,0,0}. The outer dispatch in chacha.cpp
guards every call with MultiBlockSafe(state[12], 4), so state[12] + 3
is guaranteed not to overflow into state[13] (i.e. lane 0 can never
carry into lane 1).

The observed mismatch is exactly one bit in word 13 of block 3's
output. Word 13 is the high half of the 64-bit counter — the only place
inside this function that is touched by vaddq_u64 rather than
vaddq_u32. The r3_* slot is also the last one seeded and the last
one finalised in the source, i.e. the slot most likely to be spilled
under heavy register pressure. The pattern — a single bit flipped, only
in the lane that vaddq_u64 touches, only in the last of the four
output vectors — is consistent with something going wrong specifically
around the vaddq_u64 in Add64 when the surrounding function is
under high vector-register pressure (the function keeps 16 live
uint32x4_t round-state variables plus the 4 state loads plus the 3
CTRS constants simultaneously live across the round loop, which sits
right at the 32-Q-register ceiling).

The bug reproduces on both clang 9 (NDK r21.4) and clang 17 (NDK r26.1),
which either means the same codegen issue has persisted across ~8 years
of clang releases, or the root cause is actually in the Crypto++ source
rather than in clang (e.g. undefined behaviour introduced by
vreinterpretq_u64_u32 under aliasing rules that both clang versions
happen to miscompile the same way, or an unlucky choice of intrinsic
that clang's aarch64 backend never allocates well). We have not fully
bisected either possibility.

ChaChaDecryptTest.zip

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions