33813 - [BC - Insight] Double slashing of validators

Submitted on Jul 30th 2024 at 03:08:19 UTC by @Lastc0de for Boost | Shardeum: Core

Report ID: #33813

Report type: Blockchain/DLT

Report severity: Insight

Target: https://github.com/shardeum/shardus-core/tree/dev

Impacts:

  • Direct loss of funds

Description

Brief/Intro

Slashing mechanism for POS blockchains is a critical feature. Its implementation must have a strong test suite.

Shardeum has the concept of a penalty, to slash nodes if they have malicious behaviour. For example if a node goes offline while it is in active mode, and stops processing transactions, then other nodes confirm it is a lost node and apply a configurable penalty to that nodes staked tokens. But there is a bug in the shardeum repository which causes the network to apply a penalty 2x of configured value. So when penalty of leaving network early is configured to be 20% of staked value, if a validator leaves it loses 40% of its stake.

I will explain the cause of the bug here and provide a POC after.

Vulnerability Details

One way of getting a penalty is to leave network early, in the shardeum/shardus-core repository if a node removed because it lefts network early, a node-left-early event would be triggered

src/p2p/NodeList.ts

if (isNodeLeftNetworkEarly(node)) {
    const emitParams: Omit<ShardusEvent, 'type'> = {
    nodeId: node.id,
    reason: 'Node left early',
    time: cycle.start,
    publicKey: node.publicKey,
    cycleNumber: cycle.counter,
    }
    emitter.emit('node-left-early', emitParams)
}

this event is handled in src/shardus/index.ts file of same repository

src/shardus/index.ts

Self.emitter.on('node-left-early', ({ ...params }) => {
    try {
    if (!this.stateManager.currentCycleShardData) throw new Error('No current cycle data')
    if (params.publicKey == null) throw new Error('No node publicKey provided for node-left-early event')
    const consensusNodes = this.getConsenusGroupForAccount(params.publicKey)
    for (let node of consensusNodes) {
        if (node.id === Self.id) {
        this.app.eventNotify?.({ type: 'node-left-early', ...params })
        }
    }
    } catch (e) {
    this.mainLogger.error(`Error: while processing node-left-early event stack: ${e.stack}`)
    }
})

and it calls eventNotify function of app which is a method that defined in another repository shardeum/shardeum

src/index.ts

else if (
eventType === 'node-left-early' &&
ShardeumFlags.enableNodeSlashing === true &&
ShardeumFlags.enableLeftNetworkEarlySlashing
) {
    let nodeLostCycle
    let nodeDroppedCycle
    for (let i = 0; i < latestCycles.length; i++) {
        const cycle = latestCycles[i]
        if (cycle == null) continue
        if (cycle.apoptosized.includes(data.nodeId)) {
        nodeDroppedCycle = cycle.counter
        } else if (cycle.lost.includes(data.nodeId)) {
        nodeLostCycle = cycle.counter
        }
    }
    if (nodeLostCycle && nodeDroppedCycle && nodeLostCycle < nodeDroppedCycle) {
        const violationData: LeftNetworkEarlyViolationData = {
        nodeLostCycle,
        nodeDroppedCycle,
        nodeDroppedTime: data.time,
        }
        nestedCountersInstance.countEvent('shardeum-staking', `node-left-early: injectPenaltyTx`)

        await PenaltyTx.injectPenaltyTX(shardus, data, violationData)
    } else {
        nestedCountersInstance.countEvent('shardeum-staking', `node-left-early: event skipped`)
        /* prettier-ignore */ if (logFlags.dapp_verbose) console.log(`Shardeum node-left-early event skipped`, data, nodeLostCycle, nodeDroppedCycle)
    }
}

which calls the injectPenaltyTX function. injectPenaltyTX function creates an internal penalty transactions and puts into shardus by calling shardus.put function

src/tx/penalty/transaction.ts

export async function injectPenaltyTX(
  shardus: Shardus,
  eventData: ShardusTypes.ShardusEvent,
  violationData: LeftNetworkEarlyViolationData | NodeRefutedViolationData | SyncingTimeoutViolationData
): Promise<{
  success: boolean
  reason: string
  status: number
}> {
  let violationType: ViolationType
  if (eventData.type === 'node-left-early') violationType = ViolationType.LeftNetworkEarly
  else if (eventData.type === 'node-refuted') violationType = ViolationType.NodeRefuted
  else if (eventData.type === 'node-sync-timeout') violationType = ViolationType.SyncingTooLong
  const unsignedTx = {
    reportedNodeId: eventData.nodeId,
    reportedNodePublickKey: eventData.publicKey,
    operatorEVMAddress: '',
    timestamp: shardeumGetTime(),
    violationType,
    violationData,
    isInternalTx: true,
    internalTXType: InternalTXType.Penalty,
  }

  const wrapeedNodeAccount: ShardusTypes.WrappedDataFromQueue = await shardus.getLocalOrRemoteAccount(
    unsignedTx.reportedNodePublickKey
  )

  if (!wrapeedNodeAccount) {
    return {
      success: false,
      reason: 'Penalty Node Account not found',
      status: 404,
    }
  }

  if (wrapeedNodeAccount && isNodeAccount2(wrapeedNodeAccount.data)) {
    unsignedTx.operatorEVMAddress = wrapeedNodeAccount.data.nominator
  } else {
    return {
      success: false,
      reason: