Almost all of us have used Google Sheets or Microsoft Excel to enter data for some computation. Let’s say you wish to enter the names of employees, their phone numbers, titles and the salary they earn.
In its simplest form, this is how a record or case would look, in Sheets or Excel:
As you can tell, both the employee name and title consist of text while the Phone Number and Salary consist of a sequence of numbers.
So, from a semantic standpoint, we, as humans, understand what these fields mean in the real world and can differentiate between them.
Clearly, while you don’t need a degree in Computer Science to tell the difference, how does a compiler or interpreter process this data?
This is where data types come in, and which is something that programmers either take the time to specify or not, depending on the programming language they code in.
In other words, the data points under the employee name and the title are called strings. Of course, the salary is clearly an integer, by virtue of having no decimal points. Simply put, these are data types that must be declared as such when you code, so that only the right operations associated with that data type will be performed.
This is how we declare an integer data type in Solidity:
That said, the Phone Number field in the spreadsheet above contains a data point that will be used as a unique string, but that discussion is for another day. For now, our focus will be on the primitive data type that we’ve all performed basic arithmetic with.
Yes, we’re talking about the integer data type that while being important for key arithmetic operations, has a limited range for any computation.
Probably, the most popular example of integer overflow in the real world occurs on vehicles. Otherwise known as an odometer, these devices generally track how many miles a vehicle has traveled.
So, what happens once the value of miles traveled reaches the unsigned integer value of 999999 in a six-digit odometer?
Ideally, once another mile is added, this value should reach 1000000, right? But this doesn’t happen as there is provision for a seventh digit.
Instead, the value of miles traveled resets to 000000, as shown below:
By definition, since the seventh digit isn’t available, this results in ‘overflow’ as the accurate value is not represented.
You get the picture, right?
Conversely, the opposite can also occur even if this is not so common. In other words, when the value recorded is lesser than the least value available in the range and which is otherwise known as ‘underflow’.
As we all know, computers will store integers in memory as their binary equivalent. Now, for the sake of simplicity, let’s say that you are using an 8-bit register.
= 2⁸*1 + 2⁷*1 + 2⁶*1 + 2⁵*1 + 2⁴*1 + 2³*1 + 2²*1 + 2¹*1 + 2⁰*1
= 256 + 128 + 64 + 32 + 16 + 8 + 4 + 2 + 1
= 111111111
Where each bit is 1, and as you can tell, you can’t store a value that is higher.
On the other hand, if you want to store the number 0 in the 8-bit register, this is how it would look:
= 2⁸*0 + 2⁷*0 + 2⁶*0 + 2⁵*0 + 2⁴*0 + 2³*0 + 2²*0 + 2¹*0 + 2⁰*0
= 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0 + 0
= 000000000
Where each bit is 0, which should tell you, that you cannot store a value that is lower.
In other words, the range of integers allowed for such an 8-bit register is 0–511. So, is it possible to store the integer 512 or -1 in such a register?
Of course, not. As a result, you’ll store a value that resembles the reset value of miles travelled in the odometer example, but as binary values.
Clearly, you’d need registers with a few more bits to accommodate such a number comfortably. Or else, risk the situation of overflow once again.
In the case of signed integers, we store negative integers as well. So, when we attempt to store a number that is smaller than the accepted range, or lesser than zero, as shown above, underflow occurs.
Yet again, since the point of making any computation is to obtain deterministic results, this can at best be annoying but at their worst cause the loss of millions. Particularly, when these integer overflow or underflow errors occur in smart contracts.
While integer overflow and underflow have been around for decades, their existence as a bug in a smart contract has raised the stakes. When attackers make use of such errors, they can drain the smart contract of large amounts of tokens.
Probably the first time this type of bug occurred was with block 74638, which created billions of Bitcoin for three addresses. It would take hours to resolve this error by means of a soft fork and which discarded the block, thus rendering the transaction invalid.
For one, transactions larger than 21 million bitcoins in value were rejected. This was no different for overflow transactions, much like the one that sent so much money to the three aforementioned accounts.
However, Ethereum smart contracts have experienced integer overflow and underflow too, with the BeautyChain being a prominent example too.
In this case, the smart contract contained one faulty line of code:
As a result, the attackers were theoretically able to receive an unlimited amount of BEC tokens, which could theoretically amount to a value of (2²⁵⁶)-1.
Now, let’s look at another example of a smart contract in which integer underflow/overflow occurs.
At first glance, there are two contracts that interact in this example, and which demonstrates what happens in the case of an integer overflow.
As you can see below, the contract TimeLock, allows you to deposit and withdraw funds but with a difference: you can only perform the latter only after a period of time. In this case, you can only withdraw your funds in a week’s time.
However, once you call the attack function in the Attack contract, the time lock in place is not effective anymore and which is why the attacker can withdraw the balance amount immediately.
In other words, due to causing an integer overflow with the type(uint).max+1-timeLock.locktime(address(this)) statement, the time lock is eliminated.
For example, once you’ve deployed both smart contracts using the code above, you can test as to whether the timelock holds by invoking the deposit and withdraw functions in the TimeLock contract, as shown below:
As you can see, by selecting an amount of 2 Ether, we get the smart contract balance of 2 Ether shown above:
Specifically, the specific address that holds the balance of 2 Ether can be checked by adding the address in the field of the balances function, and clicking the balances button:
However, as mentioned above, you cannot withdraw these funds yet, due to the time lock in place. When you look at the console after hitting the withdraw function, you’ll find an error indicated by the red ‘x’ symbol. As you can see below, the reason for this error is provided by the contract is “Lock time not expired”:
Now, let’s look at the deployed Attack contract, as shown below:
Now, in order to invoke the attack function, you need to deposit a value of 1 Ether or more. So, in this instance, we have selected 2 Ether, as shown below:
After this, hit ‘attack’. You’ll find the 2 Ether that you deposited will be withdrawn immediately and added to the Attack contract as evidenced by the balance of 2 Ether below:
Clearly, this is not supposed to happen due to the fact that the lengthy time lock should take effect as soon as you make the deposit. Of course, as we know the type(uint).max+1-timeLock.locktime(address(this)) statement reduces the lock time by using the increaseLockTime function. This is precisely why we are able to withdraw the Ether balance immediately.
Which brings us to the obvious question: are there ways to fix the integer overflow and underflow vulnerability?
In recognizing that the integer overflow/underflow vulnerability can be devastating, a couple of fixes to this bug have been rolled out. Let’s look at both these fixes and how they work around such an error:
Open Zeppelin, as an organization, offers much when it comes to cybersecurity technology and services, with the SafeMath library being a part of its smart contract development repository. This repo contains contracts that can be imported into your smart contract code, with the SafeMath library being one of them.
Let’s see how one of the functions within SafeMath.sol checks for integer overflow:
Now, once the computation of a+b has taken place, a check to see if c<a takes place. Of course, this would only be true in the case of an integer overflow.
With Solidity’s compiler version reaching 0.8.0 and above, checks for integer overflow and underflow are now built in. So, one can still use this library to check for this vulnerability, both when using the language and this library. Of course, if your smart contract requires a compiler version that is less than 0.8.+, then you have to use this library to avoid overflow or underflow.
Now, as mentioned earlier, if for your smart contract, you are using a compiler version that is 0.8.0 and above, this version has a built-in checker for such a vulnerability.
In fact, just to verify whether it works with the smart contract above, when changing the compiler version to “^0.8.0” and redeploying it, the following ‘revert’ error is received:
Of course, no deposit of the 2 Ether is carried out, which is because of the check on the overflow of the time lock value. As a result, no withdrawal is possible due to no funds being deposited in the first place.
Without a doubt, the Attack.attack() function call has not worked here, so it’s all good!
If there’s anything that you should gather from this lengthy blog post, it is that ignoring this vulnerability, as from the BEC attack, can prove to be costly. As you can also tell, if left unchecked, it’s easy for non-malicious errors to take place. Or just as simple for hackers to exploit this vulnerability.
Speaking of which, and using our understanding of how the BEC attack took place, recognizing this vulnerability can go a long way in preventing any attacks when writing your smart contracts, thanks to the fixes on offer. Even if there are several other smart contract vulnerabilities that lie in wait to trip you up.