Welcome back. Today we come back again to IOST. And again, today's report is on an already fixed vulnerability allowing the attacker to critically damage whole network with just sending calls to a specially crafted contract.
The proof of knowledge is provided at the end of the article as usual for already fixed vulnerabilities.
Meanwhile, Ethereum Classic has been attacked again. Oh wait, we reported that last time, right? Yes, but since then it happened again again. What? Anyway, also some DeFi "investors" became unhappy after their hotdog token lost its value. What?
Case Of Forgotten BigInt64Array
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 stops all miners and validating nodes in the system, after which the network becomes dysfunctional. In order to do that, the attacker creates a series of special transactions and propagates them to the network. When these transactions are put into blocks, all nodes that attempt to validate the blocks will quickly stop being able to validate new blocks.
Description
The codebase state at the time of writing can be seen here.
IOST implements smart contract functionality leveraging V8 JavaScript engine from Google. This allows users of IOST to deploy smart contracts written in JavaScript. However, V8 by itself is not suitable for consensus critical operation, for example because it does not guarantee determinism of execution.
This is why IOST implemented a lot of restrictions on the top of V8. We can see some of these restrictions inside of environment.js file, which is one of the scripts that are used to prepare the execution environment in a way it is safe to run an arbitrary supported smart contract. As a typical restriction implemented by IOST we can take the elimination of Date object. Obviously, if a contract called Date.now(), each validating node would see a different value. Thus before the contract is executed, environment.js sets all "dangerous" objects to null in order to make them unavailable for the contract code.
However, IOST developers failed to include BigInt64Array object among those that are nulled and in combination with a wrongly designed allocation measurement logic, this presents a critical vulnerability in IOST consensus.
Malicious Contact Code
Let's consider the following contract code:
class Contract {
init() {}
doom() {
let arr = new BigInt64Array(14000000);
let sum = BigInt(0);
for (let i = 0; i < arr.length; i+=10000) {
arr[i] = BigInt(i*13);
}
for (let i = 0; i < arr.length; i+=4777) {
sum += arr[i*13] || BigInt(18);
}
return sum.toString();
}
};
module.exports = Contract;
This contract is designed to perform allocation of memory that exceeds the limit of 100 MB imposed by IOST. When a transaction calling doom() function of this contract is executed by a validating node (including miners), the validation fails because of the memory limit violation. That seems like a correct result, and indeed it is, but it has a very unfortunate side effect as we describe below. Let's look at how the memory limit is enforced.
Memory Limit Enforcement
The contracts to be executed are analysed and IOSTContractInstruction_Incr function calls are injected literally everywhere in them in order to calculate gas usage as well as enforce the memory usage limit. At every 10th execution of IOSTContractInstruction_Incr the memory usage is checked by call to MemUsageCheck, which further relies on MemoryUsage function, which then returns a sum of V8's total heap size and ArrayBufferAllocator::GetMaxAllocatedMemSize(). So finally, we come to ArrayBufferAllocator where the bug is:
ArrayBufferAllocator::ArrayBufferAllocator(){
this->current_allocated_size = 0;
this->max_allocated_size = 0;
}
ArrayBufferAllocator::~ArrayBufferAllocator(){}
void* ArrayBufferAllocator::Allocate(size_t length) {
this->AddAllocatedSize(length);
void* data = calloc(length, 1);
return data;
}
void* ArrayBufferAllocator::AllocateUninitialized(size_t length) {
this->AddAllocatedSize(length);
void* data = malloc(length);
return data;
}
...
size_t ArrayBufferAllocator::GetMaxAllocatedMemSize(){
return this->max_allocated_size;
}
void ArrayBufferAllocator::AddAllocatedSize(size_t length){
this->current_allocated_size += length;
if (this->current_allocated_size > this->max_allocated_size)
this->max_allocated_size = this->current_allocated_size;
}
We can see that ArrayBufferAllocator::GetMaxAllocatedMemSize() returns the value of max_allocated_size field, which can only grow. Therefore, once this value is pushed over the limit, it stays there.
Exploiting Weakness
Our contact code above uses BigInt64Array, which uses ArrayBufferAllocator class for internal allocations and it also computes allocation statistics. The contract allocates more than 100 MB of memory which pushes max_allocated_size over the limit.
This by itself would not be a problem if the allocator was not reused in the future for the execution of another contract. And that is exactly what is happening in IOST. During initialisation, IOST creates pools of virtual machines:
func (vmp *VMPool) Init() error {
// Fill vmPoolBuffer
for i := 0; i < vmp.compilePoolSize; i++ {
var e = NewVMWithChannel(CompileVMPool, vmp.jsPath, vmp.compilePoolBuff)
vmp.compilePoolBuff <- e
}
for i := 0; i < vmp.runPoolSize; i++ {
var e = NewVMWithChannel(RunVMPool, vmp.jsPath, vmp.runPoolBuff)
vmp.runPoolBuff <- e
}
return nil
}
We are interested in the second pool, whose size is 400. So there is a queue of 400 of virtual machines being consumed one by one when contracts are being executed. After each use the machine is put back to the pool so that it can be later reused. Each machine in the pool has its own instance of ArrayBufferAllocator. From time to time each instance is recreated. There is the randomised mechanism that guarantees that each virtual machine can be used at most 60 times before it is recreated. During this process of recreation, a new instance of ArrayBufferAllocator is also created.
In order to exploit the vulnerabilty here, the malicious attacker deploys the contract above and then she creates more than 400 transactions which call doom() function of the contract. She propagates these transactions to the network. When these transactions are processed by validating nodes (including miners), all virtual machines in their pool are going to be used and their ArrayBufferAllocator corrupted. Once it happens that a corrupted virtual machine is picked from the pool in order to validate base transaction (the first transaction in each block), the base transaction will fail the validation despite being perfectly valid. This is because the memory limit in the allocator's instance has been reached and when the memory limit is checked, the old value is used and the limit violation is reported as the result of the perfectly valid transaction. Therefore, every new block that the node receives and tries to validate will fail to validate. Every such node on the network will stop progressing.
In case any node recreates its virtual machines as described above, the attacker can always create and propagate new malicious transactions in order to halt the network indefinitely, or at least until nodes upgrade to a fixed version.
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 - IOST - ArrayBufferAllocator has max_allocated_size field that is never reset. The allocator is reused across different runs of each VM. This can be used to corrupt the node's ability to validate blocks when max_allocated_size is pushed over the 100 MB limit by a malicious contract call.
The OTS file proving our knowledge converted to hex looks as follows:
004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e892940108f1076bc3e6b81349741d8e5a7e399293e5957a2adeb5737658d11efa020a8385f010602a1424b502d46393022e46e43ccd2d08fff01098f25ca3004845283428534d147d657808f1045e6b9c1bf0086ff5eec6bf592b2b0083dfe30d2ef90c8e2e2d68747470733a2f2f616c6963652e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267fff010f1d7deb31d79cbc2259b8ba42f2173e608f1045e6b9c1bf008f1f29820db7530d10083dfe30d2ef90c8e2c2b68747470733a2f2f626f622e6274632e63616c656e6461722e6f70656e74696d657374616d70732e6f7267f0104521c741d58dd4d1b5608c7b1075e27208f1045e6b9c1bf0085c863725c1853b240083dfe30d2ef90c8e292868747470733a2f2f66696e6e65792e63616c656e6461722e657465726e69747977616c6c2e636f6d
If you run OpenTimestamps client correctly, you should see something like this:
This proves that we created the record on 13th March 2020, well before the fix was implemented on 30th July.