Welcome to our next episode. Today we continue with Nebulas which goes, slowly but steadily, towards being the worst project we have ever analyzed. Why is that? It's because we still haven't received any reply to any of our attempts of contacting the team officials. The reported vulnerability was simply ignored. No attempt was made to fix it so far. And the rest of the code is no less buggy than the code we explored last time. We will show another example of that in the today's report.
Meanwhile, we have witnessed large reorganisations on Ethereum Classic. Not one but two attacks were performed several days apart. How? The attacker simply bought and used more hash power than the rest of the network. Simple but effective. This shows two things. One, the age old knowledge that only one proof of work chain per chip architecture can be secure. Two, the market does not care about attacks on coins. There was almost no reaction from the market, almost indistinguishable from the long term trend. Funny quote from the article above: "Ethereum Classic Labs is pursuing legal action against the attacker." It seems they want to sue the attacker for using the chain as it was designed... This is not how blockchains are supposed to work, someone should tell them.
Today's vulnerability has not been fixed yet, hence it does not need an additional proof of knowledge.
Default Logging Settings Can Be Exploited
Bug type: DoS
Bug severity: 7/10
Scenario 1
Attacker cost: low
In this scenario, the attacker's goal is to halt the network. The attacker creates and propagates a series of specially crafted transactions, which cause all miners in the network to crash. When this happens, the network becomes dysfunctional as no new blocks are being created. As long as the attacker is creating and propagating malicious transactions, the network will not recover, unless miners upgrade to a fixed version of the node.
Description
The codebase state at the time of writing can be seen here.
Nebulas implements smart contract functionality leveraging V8 JavaScript engine from Google. This allows users of Nebulas to deploy smart contracts written in JavaScript. However, V8 by itself is not suitable for consensus critical operation. This is why Nebulas implemented several restrictions on the top of V8. One of the common things that have to be prevented is infinite loop execution. For this, Nebulas implements a gas counter, which is injected into the source code of every contact, and which is responsible for halting the execution once the contract call goes out of gas. Another countermeasure is a time restriction of V8 execution. When the timeout is reached, V8 execution of the contract is stopped.
Interestingly, if the transaction's gas limit is set above MaxLimitsOfExecutionInstructions (= 10,000,000) the actual gas limit will be set to this constant and the transaction's gas limit value is not used.
When a block producer is creating a new block in Nebulas, there is 2-5 seconds timeout for the construction of the entire block. During the construction of a new block, the block producer is trying to insert transactions from the mempool to the block. Every such transaction must be executed and this execution is limited by 15 seconds timeout. If a transaction processing takes more than 15 seconds, the transaction is discarded completely.
There are two features of the Nebulas design that we exploit. First, a contract can use console.log() (as well as console.info(), console.error() etc.) to write a string to a log file on disk. This log file is enabled by default if one sets up their node as it is suggested on the Nebulas wiki page. This means it is very likely that most of the node operators, if not all, have the logging enabled and are therefore vulnerable. Second, the gas counter that is injected into the JavaScript charges a fixed amount of gas for each function call regardless of the size of the arguments that are passed to the function.
Malicious Contact Code
Let's consider the following contract code:
"use strict";
class BaseContract {
constructor() {
this.__contractName = "DosContract";
}
init() {
}
doom() {
let s = "1".repeat(1000000);
for (;;) {
console.error(s);
}
return 0;
}
}
Now, let's have a look at how the doom() method looks after the gas counter is injected:
doom() {
_instruction_counter.incr(12);let s = "1".repeat(1000000);
for (;;) {_instruction_counter.incr(1);
_instruction_counter.incr(12);console.error(s);
}
return 0;
}
As we can see we are only charged 13 units of gas for every iteration of the for loop, i.e. for one attempt to write a message to the log file on the disk. With 10,000,000 effective gas limit, we can make over 70,000 calls to console.error(), each of which writes over 1,000,000 bytes to the log file. Thus we aim to write up to 70 GB of data to the disk in one go. However, in practice, as we tested in our isolated local network, our transaction was terminated after 15 seconds during which it was only able to write about 800 MB of data to the log file. Of course, some nodes may have better hardware, but unless the node is able to write more than 70 GB of data to the disk within 15 seconds, the transaction will fail to complete. Also, nothing prevents us to increase the size of the message. Instead of 1,000,000 bytes, we could use 5,000,000 bytes and thus increase the requirement to more than 350 GB to be written in 15 seconds for the transaction to be processed on time. Moreover, it is not a requirement that the malicious transaction must not be processed on time for the attack to be efficient. It just slightly lowers the cost for the attacker.
When the transaction execution is terminated after 15 seconds, the transaction is evicted from the mempool. It does not go into the block and therefore it is not paid for. This allows the attacker to create any number of such transactions practically free of charge. If the transactions were processed, the cost would be higher, but still very low. The node, as specified on Nebulas website, is required to have at least 600 GB of disk space. If we assume that the heaviest node on the network has 2 TB of disk space instead and if we assume only 500 MB of disk consumption per doom() method call, the attacker should create about 4,000 transactions in order to make sure that all miners in the network will have their disk filled and subsequently their nodes will crash due to inability to write to the disks.