A vulnerability in the storeReceiptData function allows a malicious validator to bypass signing validation. This enables them to store arbitrary receipts and overwrite any account data on the archive server. (Including Global Account)
Root Cause
The function verifyAppliedReceiptSignatures validates signaturePack by ensuring that the signatures themselves are valid. However, it does not check whether the signatures originate from actual nodes within the system.
At first glance, this seems acceptable since signaturePack goes through a prior validation in verifyReceiptData. However, this verification contains a critical flaw. Let's examine the relevant snippet of verifyReceiptData:
// ...
const requiredSignatures =
config.usePOQo === true
? Math.ceil(votingGroupCount * config.requiredVotesPercentage)
: Math.round(votingGroupCount * config.requiredVotesPercentage)
if (signaturePack.length < requiredSignatures) {
Logger.mainLogger.error(
`Invalid receipt appliedReceipt signatures count is less than requiredSignatures, ${signaturePack.length}, ${requiredSignatures}`
)
if (nestedCountersInstance)
nestedCountersInstance.countEvent(
'receipt',
'Invalid_receipt_appliedReceipt_signatures_count_less_than_requiredSignatures'
)
return result
}
// Using a set to store the unique signatures to avoid duplicates
const uniqueSigners = new Set()
for (const signature of signaturePack) {
const { owner: nodePubKey } = signature
// Get the node id from the public key
const node = cycleShardData.nodes.find((node) => node.publicKey === nodePubKey)
if (node == null) {
Logger.mainLogger.error(
`The node with public key ${nodePubKey} of the receipt ${txId}} with ${timestamp} is not in the active nodesList of cycle ${cycle}`
)
if (nestedCountersInstance)
nestedCountersInstance.countEvent(
'receipt',
'appliedReceipt_signature_owner_not_in_active_nodesList'
)
continue
}
// Check if the node is in the execution group
if (!cycleShardData.parititionShardDataMap.get(homePartition).coveredBy[node.id]) {
Logger.mainLogger.error(
`The node with public key ${nodePubKey} of the receipt ${txId} with ${timestamp} is not in the execution group of the tx`
)
if (nestedCountersInstance)
nestedCountersInstance.countEvent(
'receipt',
'appliedReceipt_signature_node_not_in_execution_group_of_tx'
)
continue
}
uniqueSigners.add(nodePubKey)
}
if (uniqueSigners.size < requiredSignatures) {
Logger.mainLogger.error(
`Invalid receipt appliedReceipt valid signatures count is less than requiredSignatures ${uniqueSigners.size}, ${requiredSignatures}`
)
if (nestedCountersInstance)
nestedCountersInstance.countEvent(
'receipt',
'Invalid_receipt_appliedReceipt_valid_signatures_count_less_than_requiredSignatures'
)
return result
}
// ...
The Exploit Strategy
There are two primary validation steps in verifyReceiptData:
The number of signatures in signaturePack must be greater than or equal torequiredSignatures.
The number of unique public keys from valid nodes in signaturePack must also be greater than or equal torequiredSignatures.
A malicious validator can exploit these checks as follows:
Populate signaturePack with at leastrequiredSignatures valid signatures, which can be generated by any key controlled by the attacker.
Include the public keys of all valid nodes, but pair them with invalid signatures.
This results in signaturePack containing 2 * requiredSignatures entries:
Half are valid signatures produced by the attacker.
Half are invalid signatures that falsely claim to originate from valid nodes.
Despite the invalid signatures, verifyReceiptData considers the validation successful because the length condition is met.
Now, when verifyAppliedReceiptSignatures runs, it finds requiredSignatures valid signatures and passes the check, allowing the malicious validator to bypass signature validation.
Impact
A malicious validator can modify any account data stored on an archive server if it is connected to the validator via a socket.io connection. This includes the global network account, posing a significant security risk.
Proposed Fix
To prevent this exploit, verifyAppliedReceiptSignatures should:
Verify each signature only if it originates from a valid node.
Reject signatures from unverified sources before counting them.
Proof of Concept
Proof of Concept
Apply the following git diff on the Archiver for some nice logs and clarity:
diff --git a/src/GlobalAccount.ts b/src/GlobalAccount.ts
index d42adef..b77f308 100644
--- a/src/GlobalAccount.ts
+++ b/src/GlobalAccount.ts
@@ -33,8 +33,10 @@ export function getGlobalNetworkAccount(hash: boolean): object | string {
}
export function setGlobalNetworkAccount(account: AccountDB.AccountsCopy): void {
+ Logger.mainLogger.info(`changing cachedGlobalNetworkAccountHash: before ${cachedGlobalNetworkAccountHash}`)
cachedGlobalNetworkAccount = rfdc()(account)
cachedGlobalNetworkAccountHash = account.hash
+ Logger.mainLogger.info(`changing cachedGlobalNetworkAccountHash: after ${cachedGlobalNetworkAccountHash}`)
}
interface NetworkConfigChanges {
Now, you'll be able to see the logs of the global account changing:
[2025-02-09T16:12:45.427] [INFO] main - changing cachedGlobalNetworkAccountHash: before 47d6e983d5bda803e4517e6b883fc5b0e862ebd3f68ba77288fba9fb10e2d1e4
[2025-02-09T16:12:45.427] [INFO] main - changing cachedGlobalNetworkAccountHash: after 2ef35235179170de0b834e4c39830f443e010789102ff7e5e5708c2d27979862
This vulnerability has the same impact as the issue reported