Welcome back. Once more today we come back to IOST. After some cooperation with the team, we were told the funds were exhausted for our cause and hence they can't incentivize our efforts anymore. Since the incentive is gone, today's report is somewhat incomplete. Usually we deeply investigate the potential impact of exploitation of the published vulnerability, today we just explain the vulnerability and give some ideas about how it can be used, some of which we have not verified with actual code.
Today's vulnerability is not fixed yet, so no additional proof is necessary. Enjoy the read!
Decode Before Check
Bug type: Bypassing designed restrictions
Bug severity: 4+/10
Scenario 1
Attacker cost: very low
In this scenario the attacker's goal is to save fees on execution of their contract. The attacker inserts into her contracts a special code that switches off the internal gas counter and is thus able to use unlimited amount of gas for execution. The execution is still limited with time limits, but the attacker can save a lot on fees that she would otherwise had to spend. This is the scenario that we actually verified and on which we base the bug severity.
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 inject_gas.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. One of the functions that implement restrictions is checkInvalidKeyword, which is responsible for recognising forbidden keywords, identifiers, and literals. Probably the most interesting identifier that is forbidden by this function is _IOSTInstruction_counter. This object implements gas consumption inside of the contract. Calls to _IOSTInstruction_counter.incr() are injected into every user deployed contract in order to calculate gas usage properly. Therefore this object and its methods must be protected and not be accessible to the contract itself.
However, it is possible to bypass the functionality of checkInvalidKeyword using simple Unicode encoding.
Malicious Contact Code
Let's consider the following contract code:
class Contract {
init() {}
doom(n) {
\u{005F}IOSTInstruction_counter.incr = function (i) { return 0; };
// Make expensive calculations here...
}
}
module.exports = Contract;
This contact is perfectly valid according to the rules of IOST and it even passes checks of checkInvalidKeyword despite the fact that _IOSTInstruction_counter identifier is presented in the contract. This is because we encoded it as "\u{005F}IOSTInstruction_counter" and so the string comparison code inside of checkInvalidKeyword fails to catch it. This allowed us to replace _IOSTInstruction_counter.incr() function with our own implementation that returns zero. This means all attempts to calculate gas usage are eliminated and the contract is never terminated due to out of gas condition.
Potential Impact
What is the potential impact of this bug then? We already mentioned the scenario in which the attacker simply uses this bug to save on contract execution fees. This is straightforward and we tested that it works well. But that is rather week. Can we do more? This is where we want to give the chance to explore this more to our readers. For this exercise, you can consider the following ideas.
Without this bug exploited, an infinite loop is stopped very soon because the gas is exhausted. With this bug exploited, this is no longer true and gas limits are void. But we know there are other limits. For example, as we mentioned in IOST – Timed Out Transaction Validation Problem report, there is a memory limit that is implemented inside of the original _IOSTInstruction_counter.incr(), which we replaced. Can you figure out how to exhaust the memory? The problem might be there because of time limits on contract execution (see the previous report again for some information about them). So maybe such a time limit would prevent one to exhaust the memory, but maybe some clever construction could be used to consume the memory very fast.
Another idea to consider is a possibility to split the network. If there is a time limit for execution, certainly one can create such a code that it takes just about that maximum time period that it is allowed for a contract to run. On some machines with better CPU or lower load at the time of the contract execution, such a code would just make it before the limit stops the execution. On other machines, however, the code would need little more time and the limit would enforce the contract to stop. So suppose the transaction with executing such code is propagated and a miner mines it successfully. Then the block with such a transaction is propagated to the network. One would expect some nodes would not make it and mark the block as invalid. But there is a catch inside verify function in verifier/verifier.go. You can see there that in this case the time limit for validating nodes is 2 times greater than for the miner (and later it was actually increased to 50 times). So this is probably no go, but feel free to explore if there are other similar possibilities.