bitcoinjs-lib Accepts Forged Blocks (CVE-2026-107846)
bitcoinjs-lib up to 7.0.1 accepts a forged transaction list under a real block id; fixed in 5.2.1, 6.1.8 and 7.0.2.
TL;DR
- bitcoinjs-lib validates a forged block whose transaction list differs from the real block. A peer duplicates the last transaction to fill the Merkle tree's odd-node padding, and
checkTxRoots()still returns true. - Versions 2.3.0 through 5.2.0, 6.0.0 through 6.1.7 and 7.0.0-rc.0 through 7.0.1 are affected. The flaw is in block validation, not in transaction signing.
- The CVE record rates it CVSS v4.0 9.3 Critical, while the repository advisory rates it high. A public proof of concept is in the advisory; no exploitation is known, and it is not in CISA KEV as of 2026-11-09.
- Upgrade to 5.2.1, 6.1.8 or 7.0.2. If you validate untrusted blocks, also reject any block that repeats a transaction ID.
Am I affected?
All of these must hold:
- You use
bitcoinjs-libversion 2.3.0 through 5.2.0, 6.0.0 through 6.1.7, or 7.0.0-rc.0 through 7.0.1. - You parse blocks from an untrusted source and act on the result of
Block.checkTxRoots(),Block.calculateMerkleRoot()or the block's ID. - A wrong answer has a consequence in your system, such as crediting a deposit, indexing a transaction twice, or accepting a block that another node rejects.
The affected code is the block validator that the library added in 2.3.0 as Block.checkMerkleRoot(), later exposed as Block.checkTxRoots(). Code that only builds or signs transactions does not call it. A wallet that never parses a block from a peer is not exposed through this path.
| Branch | Affected versions | Fixed version |
|---|---|---|
| 7.x | 7.0.0-rc.0 through 7.0.1 | 7.0.2 |
| 6.x | 6.0.0 through 6.1.7, including 6.1.0-rc.0 | 6.1.8 |
| 5.x and earlier | 2.3.0 through 5.2.0 | 5.2.1 |
| Before 2.3.0 | No block-root verifier | Not affected |
The repository published five other advisories on 2026-10-26: a PSBT fee understatement, two denial-of-service parsing issues, an invalid compact-target acceptance, and a conflicting-UTXO fee bug. They are separate flaws with their own fixed versions, later than the ones for this CVE: 5.2.3, 6.1.10 and 7.0.5, which npm lists as the current maintenance and latest releases.
Check what is installed:
npm ls bitcoinjs-lib
node -p "require('bitcoinjs-lib/package.json').version"
grep -n '"node_modules/bitcoinjs-lib"' -A 4 package-lock.json
grep -n "bitcoinjs-lib" yarn.lock pnpm-lock.yaml
Then find the call sites that matter:
grep -rn "checkTxRoots\|calculateMerkleRoot\|checkMerkleRoot" --include="*.js" --include="*.ts" .
How do I fix it?
Upgrade to the fixed release on your branch: 5.2.1, 6.1.8 or 7.0.2. All three shipped on 2026-09-07, seven weeks before the advisory was published.
- For the 7.x line:
npm install bitcoinjs-lib@7.0.2
- For the 6.x line:
npm install bitcoinjs-lib@6.1.8
- For the 5.x line:
npm install bitcoinjs-lib@5.2.1
- Versions earlier than 5.0.0 have no in-line patch. Move to a fixed line, or add the application-level check in this section before you trust a block.
If you cannot upgrade immediately, reject any block whose transaction list repeats a transaction ID before you call checkTxRoots(). A repeated ID is the observable form of the mutation, and it is simpler to audit than the internal padding condition.
// Application-level guard, adapted from the advisory's remediation note.
function hasRepeatedTxid(block) {
const ids = block.transactions.map(tx => tx.getId());
return new Set(ids).size !== ids.length;
}
if (!hasRepeatedTxid(block) && block.checkTxRoots()) {
// accept the block
}
That guard covers the duplicated-transaction form of the attack. It does not turn the library into a full block validator, and it does not replace the upgrade.
What is the root cause?
The Merkle root routine pads an odd level by hashing the last node with itself and returns the root without reporting that it padded. Bitcoin's Merkle tree construction does the same padding. Bitcoin Core also scans each level for two adjacent equal hashes and raises a mutated flag, which CheckBlock turns into a bad-txns-duplicate rejection. bitcoinjs-lib performed the padding and dropped the signal.
In ts_src/merkle.ts at v7.0.1, fastMerkleRoot duplicates the last node when a level has an odd length:
let length = values.length;
const results = values.concat();
while (length > 1) {
let j = 0;
for (let i = 0; i < length; i += 2, ++j) {
const left = results[i];
const right = i + 1 === length ? left : results[i + 1];
const data = tools.concat([left, right]);
results[j] = digestFn(data);
}
length = j;
}
return results[0];
When i + 1 === length, right is set to left, so the last node is hashed with itself. There is no comparison with the previous node and no output parameter to report a duplicate. The function returns only the root.
Block.calculateMerkleRoot passes that root through unchanged, and Block.__checkMerkleRoot compares it with the header field alone:
private __checkMerkleRoot(): boolean {
if (!this.transactions) throw errorMerkleNoTxes;
const actualMerkleRoot = Block.calculateMerkleRoot(this.transactions);
return tools.compare(this.merkleRoot!, actualMerkleRoot) === 0;
}
checkTxRoots() adds the witness-commit check and then calls __checkMerkleRoot(). A header that commits to the real root therefore passes, even when the transaction list carries an extra copy of the last transaction that the padding absorbed. The attacker does not need to mine: the header, the proof of work and the block ID stay the same.
A block ID hashes only the 80-byte header, and the header authenticates the transaction list only through the Merkle root. Any change to the list that preserves the root therefore preserves the ID and every lookup keyed on it.
The same duplication class is CVE-2012-2459, the mutation that Bitcoin Core fixed in 2012. The advisory states that Core rejects the case through ComputeMerkleRoot and CheckBlock. This is a parity gap in the library, not a new construction flaw in Bitcoin.
How is it exploited?
The attack takes an existing block and changes its transaction list without changing its header:
- The attacker takes a real block from the chain, with a valid header and proof of work, whose Merkle tree has an odd node count at some level.
- The attacker parses the block, appends a copy of the last transaction to the transaction list, and serializes it again. This adds 259 bytes in the advisory's mainnet example and leaves the header untouched.
- The attacker relays the mutated block to a service that parses blocks with bitcoinjs-lib and gates on
checkTxRoots(). - The service recomputes the Merkle root. The duplicated transaction fills the odd-node padding, so the root and the block ID match the header, and
checkTxRoots()returns true. - The service now sees a block with a different transaction list under an ID that also belongs to the honest block. An indexer can credit the duplicated transaction a second time, or a deposit system can count it twice.
The advisory notes that most real blocks reach an odd node count at some level, so most existing blocks can be rewritten.
Its proof of concept runs against a real mainnet block from the repository's Bitcoin Core fixture set. The honest block has nine transactions and validates; the forged copy has ten transactions, 259 more bytes and the same block ID, and it also validates. The advisory also extends the same block to twelve transactions with the same result.
// Adapted from the advisory's proof of concept, trimmed to the mutation.
const honest = Block.fromHex(fixture.hex);
const forged = Block.fromHex(fixture.hex);
forged.transactions = forged.transactions.concat([
forged.transactions[forged.transactions.length - 1],
]);
const parsed = Block.fromHex(forged.toHex());
honest.getId() === parsed.getId(); // true
parsed.checkTxRoots(); // true on v7.0.1
How does the attack flow?
A peer rewrites an existing block by duplicating its last transaction. The library's root computation accepts the rewritten list because it never reports the padding that made the duplicate invisible.
flowchart LR
A["Peer relays a mutated block"] --> B["Block.fromHex parses duplicate transactions"]
B --> C["fastMerkleRoot pads the odd level"]
C --> D["no duplicate-pair check"]
D --> E["checkTxRoots returns true"]
E --> F["Indexer credits the duplicate transaction"]
What can an attacker do?
The impact is in the consumer, not in the library. With a forged block accepted, an attacker can:
- Cause a service that credits transactions to credit the same transaction twice, when the service uses
checkTxRoots()as its block integrity gate. - Make an explorer or indexer store a transaction list that does not match the chain, under a block ID that does.
- Trigger the denial of service described in CVE-2012-2459: a consumer that reacts by blacklisting the block ID also rejects the honest block with that ID.
The attack needs a consumer that parses untrusted blocks and acts on checkTxRoots(). It does not recover private keys, sign transactions, or let the attacker spend someone else's coins directly. The advisory is explicit that severity depends on the consumer, because block validation is a smaller part of this library's use than transaction handling.
A service that only signs or broadcasts transactions is not the target. The exposure sits on the reading path: indexers, exchanges, explorers and any deposit pipeline that accepts a block because the library said its transaction root matched.
Why does the fix work?
The fix reports the padding case out of the root computation and makes block validation fail when a real duplicate pair is found. Commit b79c7714c0687473dc6e25c69fc5fc597302c7a0 adds a mutated output parameter to fastMerkleRoot and checks it in __checkMerkleRoot.
export function fastMerkleRoot(
values: Uint8Array[],
digestFn: (b: Uint8Array) => Uint8Array,
+ mutated?: { value: boolean },
): Uint8Array {
...
let length = values.length;
const results = values.concat();
+ if (mutated) mutated.value = false;
while (length > 1) {
let j = 0;
for (let i = 0; i < length; i += 2, ++j) {
const left = results[i];
const right = i + 1 === length ? left : results[i + 1];
+ // Odd-node padding is not a mutation; only compare pairs from this level.
+ if (mutated && i + 1 < length && tools.compare(left, right) === 0)
+ mutated.value = true;
const data = tools.concat([left, right]);
results[j] = digestFn(data);
}
The condition i + 1 < length is the important part. It compares two hashes that are really siblings at this level, and it excludes the synthetic pair created by the odd-node padding. A single value is not flagged, and two equal hashes that sit in different pairs are not flagged either, which the new tests in the commit cover.
__checkMerkleRoot then returns false when the flag is set:
private __checkMerkleRoot(): boolean {
if (!this.transactions) throw errorMerkleNoTxes;
- const actualMerkleRoot = Block.calculateMerkleRoot(this.transactions);
- return tools.compare(this.merkleRoot!, actualMerkleRoot) === 0;
+ const mutated = { value: false };
+ const actualMerkleRoot = Block.calculateMerkleRoot(
+ this.transactions,
+ false,
+ mutated,
+ );
+ return (
+ !mutated.value && tools.compare(this.merkleRoot!, actualMerkleRoot) === 0
+ );
}
The patch exists in all three fixed lines: commit b79c7714c0687473dc6e25c69fc5fc597302c7a0 for 7.x, 009f165edfbf925cad8964b2dce478d18dd19449 for 6.x, and f3434d1e4bb5e0784fe769c4cd08a243576232a1 for 5.2.1, which also replaces the external merkle-lib dependency with a local implementation. The fix is complete for the library's block-root path. It does not add a repeated-transaction check to every caller's own Merkle code, which is why the application-level guard remains useful.
How do I check if I was compromised?
There is no host compromise to hunt here: the attack changes which blocks a consumer accepts, so the evidence is in block and deposit records. Look for blocks whose stored transaction list disagrees with a second source, and for repeated transaction IDs inside one block.
- Compare stored blocks against a second Bitcoin node or explorer for the same height and block ID.
- Scan for a block whose transaction list contains a duplicate
txid. - Audit credits and deposits that were based on a block validated by the old library.
- Re-run
checkTxRoots()with a fixed release over stored blocks and record any block it now rejects.
# List transaction IDs in a stored block and find duplicates with jq.
jq -r '.tx[] | .txid' stored-block.json | sort | uniq -d
# Compare the block ID your service recorded with a second node.
bitcoin-cli getblockhash <height>
bitcoin-cli getblock <blockhash> 2
A block that fails the second source comparison is evidence to investigate. A block that matches is not proof that your consumer handled it correctly, because the flaw is in the consumer's validation path, not in the chain.
Timeline
- 2026-09-07: the fixes ship in 5.2.1, 6.1.8 and 7.0.2.
- 2026-10-08: the CVE is reserved.
- 2026-10-26: the repository publishes six advisories, including GHSA-8vv3-6qpc-gpw6.
- 2026-11-04: the CVE record for CVE-2026-107846 is published.
FAQ
Can this flaw steal bitcoin from a wallet?
No, not by itself. It does not expose keys or create valid signatures. The theft risk is indirect: a service that credits deposits from a block it validated with the old library can be tricked into crediting the same transaction twice.
Does Bitcoin Core accept the forged block?
No. The advisory states that Bitcoin Core detects the mutation in ComputeMerkleRoot and rejects the block with bad-txns-duplicate. The flaw is a difference between bitcoinjs-lib's block validator and Core's behaviour.
Do I need to upgrade if I only create transactions?
Not for this flaw, unless your code also parses blocks and calls checkTxRoots(). Transaction building and signing do not go through the affected block-root path.
Why does the forged block keep the same ID?
The block ID is the hash of the header, and the header is unchanged. The duplicate transaction only changes the transaction list and the block's serialized size, both of which sit outside the header hash.
Is there a workaround if I cannot upgrade?
Yes, for the duplicated-transaction form. Reject any block that repeats a transaction ID before trusting checkTxRoots(). The guard is shown in "How do I fix it?", and it should be treated as temporary until the library is upgraded.
Find outdated bitcoinjs-lib copies with Ostorlab
Ostorlab's software composition analysis and SBOM inventory records the versions of libraries such as bitcoinjs-lib across your repositories and container images. That lets you find the services still resolving a version earlier than 5.2.1, 6.1.8 or 7.0.2.
References
- Repository advisory GHSA-8vv3-6qpc-gpw6, "Undetected merkle node duplication in block root computation validates a forged transaction list" (2026-10-26): https://github.com/bitcoinjs/bitcoinjs-lib/security/advisories/GHSA-8vv3-6qpc-gpw6 (checked November 9, 2026)
- CVE record CVE-2026-107846 (2026-11-04): https://www.cve.org/CVERecord?id=CVE-2026-107846 (checked November 9, 2026)
- Fix commit b79c7714c0687473dc6e25c69fc5fc597302c7a0 (2026-09-03): https://github.com/bitcoinjs/bitcoinjs-lib/commit/b79c7714c0687473dc6e25c69fc5fc597302c7a0 (checked November 9, 2026)
- Fix commit 009f165edfbf925cad8964b2dce478d18dd19449 for the 6.x line (2026-09-03): https://github.com/bitcoinjs/bitcoinjs-lib/commit/009f165edfbf925cad8964b2dce478d18dd19449 (checked November 9, 2026)
- Fix commit f3434d1e4bb5e0784fe769c4cd08a243576232a1 for 5.2.1 (2026-09-07): https://github.com/bitcoinjs/bitcoinjs-lib/commit/f3434d1e4bb5e0784fe769c4cd08a243576232a1 (checked November 9, 2026)
- CVE-2012-2459, the Merkle tree mutation class (checked November 9, 2026): https://www.cve.org/CVERecord?id=CVE-2012-2459
- npm download count for bitcoinjs-lib, November 2 to 8, 2026: https://api.npmjs.org/downloads/point/last-week/bitcoinjs-lib (checked November 9, 2026)