Zero DH shared secret and key material after use in HPKE KEM#71
Open
vijaypatilraje-hue wants to merge 1 commit into
Open
Zero DH shared secret and key material after use in HPKE KEM#71vijaypatilraje-hue wants to merge 1 commit into
vijaypatilraje-hue wants to merge 1 commit into
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In
X25519HpkeKem,NistCurvesHpkeKem, andSecretBigInteger, intermediatesecret byte arrays are created on the JVM heap and never zeroed after use.
Three categories of secret material are affected:
dhSharedSecret) — the root intermediate valuefrom which all HPKE session keys are derived — is left readable in heap
memory after
decapsulate()/authDecapsulate()returns.authDecapsulate()paths, the recipient's rawprivateKeybyte[] copyis also left unzeroed in heap.
SecretBigInteger.equalsSecretBigInteger(), temporary byte[] copies ofRSA primes
pandqcreated viatoByteArray()are never zeroed afterthe constant-time comparison returns.
PoC result (confirmed on Ubuntu 24.04 / OpenJDK 21 / WSL2, tink-java HEAD
commit
d0482dcc, main branch, 10 June 2026):dhSharedSecret: 32/32 bytes (100%) survive in heap afterdecapsulate()returnsdhSharedSecret: 32/32 bytes (100%) survive in heap afterdecapsulate()returnspbyte[]: 129/129 bytes (100%) survive afterequalsSecretBigInteger()returnsArrays.fill(dhSharedSecret, (byte)0)applied →0/32 bytes survive — fix confirmed effective ✅
Root Cause
Files:
tink/hybrid/internal/X25519HpkeKem.java— lines 145–166tink/hybrid/internal/NistCurvesHpkeKem.java— lines 151–184tink/util/SecretBigInteger.java— lines 66–69Verified on HEAD commit
d0482dcc(10 June 2026, current main branch).X25519HpkeKem.java —
decapsulate()(lines 145–149)X25519HpkeKem.java —
authDecapsulate()(lines 157–166)NistCurvesHpkeKem.java —
decapsulate()(lines 151–155)NistCurvesHpkeKem.java —
authDecapsulate()(lines 165–184)SecretBigInteger.java —
equalsSecretBigInteger()(lines 66–69)Affected Callsites
All 5 locations verified line-by-line against HEAD
d0482dcc.tink/hybrid/internal/X25519HpkeKem.javadecapsulate()dhSharedSecrettink/hybrid/internal/X25519HpkeKem.javaauthDecapsulate()privateKey,dh1,dh2,dhSharedSecrettink/hybrid/internal/NistCurvesHpkeKem.javadecapsulate()dhSharedSecrettink/hybrid/internal/NistCurvesHpkeKem.javaauthDecapsulate()dhSharedSecrettink/util/SecretBigInteger.javaequalsSecretBigInteger()myArray,otherArrayVerification — no
Arrays.fillanywhere in these files today:Why Tink's Existing Protections Don't Cover This
Tink provides
SecretData,SecretBytes, andSecretBigInteger— alldemonstrating that the team understands the principle of zeroing secret
material. However, these abstractions protect stored key material. They
do not protect intermediate byte arrays created during cryptographic
operations within the KEM layer.
dhSharedSecretis created bycomputeSharedSecret(), passed toderiveKemSharedSecret()/extractAndExpand(), and then discarded — butnever zeroed. It is an intermediate value owned entirely by the KEM layer.
The caller receives only the final derived session key and has no access to
dhSharedSecret. Only tink-java can zero it.Java GC does not guarantee timely zeroing. The GC may run seconds,
minutes, or never (in a long-running server process) after the method returns.
During this entire window
dhSharedSecretis recoverable from heap memory.This is precisely the use case for
Arrays.fill()— which exists in the JDKfor this purpose. All major Java crypto libraries use it:
finallyblocksjavax.crypto—SecretKeySpecimplementsjavax.security.auth.DestroyableSteps to Reproduce
Environment: Ubuntu 24.04, OpenJDK 21, WSL2
Tested on: tink-java HEAD
d0482dcc(10 June 2026, current main branch)Step 1 — Clone and confirm vulnerable state:
Step 2 — Compile and run the PoC (pure JDK 11+, no dependencies):
javac PocTinkJavaDhResidue.java java -cp . PocTinkJavaDhResidueActual output (Ubuntu 24.04 / OpenJDK 21 / WSL2):
PoC Methodology
The PoC directly mirrors each vulnerable code path in tink-java using standard
JDK APIs (
KeyAgreement,KeyPairGenerator,BigInteger). For each path:Arrays.fill()zeroes it completely (differential proof)Phase 5 (Deterministic Differential) is allocator-layout independent: it
uses the same keypair for both runs and verifies byte counts directly,
proving
Arrays.fill()is the correct and sufficient fix.Proposed Fix
Wrap each
returnintry { ... } finally { Arrays.fill(..., (byte)0) }toguarantee zeroing on both the normal return path and all exception paths.
A complete ready-to-apply patch covering all 5 locations across 3 files
(3 files changed, 36 insertions, 12 deletions) is attached.
X25519HpkeKem.java —
decapsulate()X25519HpkeKem.java —
authDecapsulate()NistCurvesHpkeKem.java —
decapsulate()NistCurvesHpkeKem.java —
authDecapsulate()SecretBigInteger.java —
equalsSecretBigInteger()Patch verification:
References
https://cwe.mitre.org/data/definitions/226.html
javax.security.auth.Destroyable—https://docs.oracle.com/en/java/docs/api/java.base/javax/security/auth/Destroyable.html
https://www.rfc-editor.org/rfc/rfc9180#section-4.1
d0482dcc):Who Can Exploit This
Any attacker who can read heap memory within a JVM process using tink-java
for HPKE decryption or RSA key operations. Exploitation does not require
triggering the vulnerability directly — only reading heap memory after a
normal tink-java cryptographic operation has completed and its intermediate
byte arrays are waiting for GC.
Attack surfaces include:
same JVM process (e.g. a deserialization gadget, a
ByteBufferover-read,or an insecure reflection path) returns bytes from a recently used heap
region containing an unzeroed
dhSharedSecretjmap -dump,-XX:+HeapDumpOnOutOfMemoryError,APM agents (DataDog, NewRelic, AppDynamics), and profiling tools all
produce readable heap snapshots. Any such snapshot of a running tink-java
process exposes all unzeroed
dhSharedSecretarrays/proc/<pid>/mem, core dumps,swap/hibernation images, cold-boot attacks on DRAM
an exited JVM process may be recoverable by an attacker with host access
high HPKE throughput, the heap accumulates many unzeroed
dhSharedSecretarrays simultaneously, amplifying the exposure window
What the Attacker Gains
decapsulate()— X25519dhSharedSecret32 bytesauthDecapsulate()— X25519privateKey32 bytes +dhSharedSecret64 bytesdecapsulate()— P-256dhSharedSecret32 bytesdecapsulate()— P-384dhSharedSecret48 bytesdecapsulate()— P-521dhSharedSecret66 bytesauthDecapsulate()— P-256/384/521dhSharedSecret32–66 bytesequalsSecretBigInteger()p,qas byte[] (129 bytes each for RSA-2048)Severity Factors
1.
dhSharedSecretis the most sensitive intermediate value in HPKE.It is the direct input to
extractAndExpand()(HKDF-Extract + HKDF-Expand)from which all HPKE session keys are derived per RFC 9180 §4.1. Recovery of
dhSharedSecretgives an attacker everything needed to reconstruct everykey in that HPKE session.
Unlike the final KEM shared secret — which is returned to the caller who
could choose to zero it —
dhSharedSecretis created and consumed entirelywithin the KEM layer. The caller has no access to it. Only tink-java can
zero it. Currently it does not.
2. The
authDecapsulate()path also exposes the private key scalar.In
X25519HpkeKem.authDecapsulate(),getSerializedPrivate().toByteArray()creates an explicit 32-byte copy of the recipient's private key on the heap.
This copy is never zeroed. An attacker recovering it can decrypt all past and
future sessions using that key — not just the single session.
3. Long-lived server processes amplify exposure dramatically.
A production server handling HPKE requests accumulates unzeroed
dhSharedSecretarrays from every session. In a process handling 1,000HPKE decapsulations per second, thousands of unzeroed secrets coexist in
heap simultaneously. The exposure window is not a single GC cycle — it is
the entire lifetime of all those arrays until GC collects them.
4. JVM heap dumps are a routine operational practice.
OOM dumps, profiling agents, APM platforms, and debugging tools all produce
readable heap snapshots as standard operational procedure. Any such snapshot
of a production tink-java process exposes every
dhSharedSecretarray thathas not yet been GC'd. This is not a theoretical attack — it is a normal
DevOps operation that accidentally becomes a key extraction attack.
5. Directly contradicts tink's own security model.
SecretData,SecretBytes, andSecretBigIntegerdemonstrate that theteam understands the principle of zeroing secret material after use. This is
a gap in applying that principle to intermediate KEM values. The fix is
minimal —
try { ... } finally { Arrays.fill(...) }— and has zero risk ofcorrectness regression.
6. Not known, not by design, not patched.
grep -rn "Arrays.fill"on all three affected files returns zero results.No commit, PR, issue, or comment in the repository addresses zeroing of
dhSharedSecretor KEM intermediate values.Real-World Attack Scenario
A payment processing service uses tink-java for HPKE decryption of
customer payment tokens (card numbers, CVVs, billing addresses). The service
also processes JSON bodies from client requests in the same JVM process.
An attacker submits a crafted JSON payload that triggers a heap read in the
JSON parsing layer (e.g. via a
ByteBufferover-read in a custom deserializer),returning bytes from a recently used heap region. The
dhSharedSecretbyte[]from a recent
decapsulate()call is still alive in heap — GC has not run yet.The attacker recovers the 32-byte X25519 DH output, reconstructs the HPKE
session keys via
extractAndExpand(), and decrypts the payment token thatwas processed in that session — recovering the cardholder's plaintext data.
For the
authDecapsulate()path, the attacker also recovers the 32-byteprivate key scalar. With the private key recovered, the attacker can decrypt
every past and future payment token processed by that key — a complete
long-term key compromise requiring full key rotation across all clients.
Patch Reward Eligibility
(same
tink-cryptoorganization as tink-cc, confirmed OT0 by Google VRP)Removed Before Reuse (cryptographic weakness / information disclosure)
Arrays.fill()after use ofsecret intermediates, consistent with
javax.security.auth.Destroyableminimal, zero API changes, zero correctness risk
active through end of 2026
allZero=trueafter fix, with0/32 non-zero bytes in differential test
poc_tinkjava-otput & patch-evidence.zip
PocTinkJavaDhResidue & write_patch.zip