CertiK
Posted on May 31, 2023
BNB Chain is one of the most in-demand blockchains in the Web3 world thanks to its low fees, fast transactions, and rich ecosystem of projects. As with any blockchain, it's important for builders on BNB Chain to prioritize security in their development process, as any loss of user funds leads to the erosion of confidence in protocols and platforms. Security breaches and hacks are among the biggest risks developers face. In this post, we provide our top 10 security tips on for developers can reduce risk and develop secure smart contracts on BNB Chain.
#1: Protect Against Replay Attacks
Replay attacks are a common type of attack in blockchain environments that exploit vulnerabilities in signature verification processes. These attacks can have serious consequences for both users and developers, as they allow attackers to repeatedly use the same signature to gain unauthorized access to funds or other assets held by a smart contract.
To prevent replay attacks, developers must carefully design and implement their smart contract code, and to follow industry-standard best practices for signature verification and security.
This code snippet represents a basic implementation of a transfer function for a token on the BNB chain which is vulnerable to replay attacks, which would enable an attacker to repeatedly use the same signature.
function transfer(address to, uint256 value, bytes calldata signature) public returns (bool) {
require(value <= balanceOf[msg.sender], "Insufficient balance.");
require(_verifySignature(msg.sender, to, value, signature), "Invalid signature.");
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
return true;
}
function _verifySignature(address from, address to, uint256 value, bytes memory signature) internal view returns (bool) {
bytes32 messageHash = keccak256(abi.encodePacked(from, to, value));
bytes32 ethSignedMessageHash = keccak256(abi.encodePacked("\x19Binance Signed Message:\n32", messageHash));
address recoveredAddress = ECDSA.recover(ethSignedMessageHash, signature);
return recoveredAddress == from;
}
This function lacks nonce or replay protection, allowing an attacker to replay a signed transfer transaction multiple times. An attacker can intercept the signed transaction and send it again to the same contract or another contract, and it will still be considered valid by the contract. This could lead to the attacker stealing the protocol’s assets. Remediations include adding a nonce in the signature or using a mapping to record the use of a signature. The exact solution may rely on the design of the project and can be modified accordingly to match the needs of specific contracts.
#2: Beware of Reentrancy Attacks
A reentrancy attack occurs when a malicious contract repeatedly calls back into a vulnerable contract before the initial invocation is complete. In other words, the attacker tricks the vulnerable contract into thinking that it is done with a transaction and is free to move on to the next one, when in reality it is still executing the attacker's malicious code. This can result in the attacker being able to manipulate the state of the contract in unexpected ways and potentially gain access to unauthorized funds.
In the following code snippet, users can withdraw funds from their account by calling the withdraw function and specifying the amount they want to withdraw. However, the withdraw function is vulnerable to a reentrancy attack because it does not properly prevent recursive calls to the function.
mapping (address => uint) public balances;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
(bool success, ) = msg.sender.call.value(_amount)("");
if (success) {
balances[msg.sender] -= _amount;
}
}
An attacker can exploit this vulnerability by creating a malicious contract that calls the withdraw function multiple times before the balance is actually deducted from their account. The function msg.sender.call sends funds to the malicious contract where the the attacker repeatedly withdraws funds, through the malicious contract receive() function before their balance is reduced to zero, effectively draining the victim contract of all its funds.
contract MaliciousContract {
receive() external payable {
VictimContract.withdraw();
}
}
A simple fix would be to include a status update before any external calls.
mapping (address => uint) public balances;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
balances[msg.sender] -= _amount;
(bool success, ) = msg.sender.call.value(_amount)("");
require(success, "Transfer failed.");
}
This is called the Check-Effects-Interact pattern, which is a design pattern used to prevent reentrancy attacks in smart contracts. It involves separating the state changes from the external calls to other contracts by first checking the preconditions and then updating the state before making any external calls. This way, if an external call triggers a callback that attempts to call back into the contract, the state has already been updated, preventing any unintended effects. By following this pattern, developers can ensure that their contracts are more secure and less vulnerable to reentrancy attacks. Another possible fix is to make use of a modifier to restrict multiple calls to the same function, much like OpenZeppelin’s ReentrancyGuard implementation.
#3: Be Careful With Oracles
An oracle helps smart contracts retrieve information from outside the blockchain. Often on a decentralized exchange (DEX), the price of assets is determined by an oracle mechanism that pulls the price from the last successful trade on the DEX. The problem is that this price can be manipulated easily by anyone, causing the smart contract to execute in unexpected ways. This manipulation can happen through the use of a flash loan, which allows a user to borrow huge amounts of funds without any collateral, as long as the loan is repaid within the same block. Since the price oracle in question does not protect against this type of manipulation, attackers can easily influence the price to profit from false liquidations, excessive loans, or unfair trades. This type of attack is called an "oracle manipulation attack."
The following is an example of code that is vulnerable to an oracle manipulation attack.
function getAmountOut(uint _amountIn) public returns (uint) {
(uint reserveA, uint reserveB, ) = IUniswapV2Pair(router.WETH()).getReserves();
uint amountOut = router.getAmountOut(_amountIn, reserveA, reserveB);
return amountOut;
}
This contract allows users to swap Token A for Token B using the Uniswap router, but it relies on an external oracle (the Uniswap pair contract) to get the reserves of Token A and Token B in order to calculate the price. An attacker is able to manipulate the reserves of the Uniswap pair contract and can manipulate the getAmountOut function, causing the swap to be executed at an unfavorable price.
To prevent this type of attack, developers should instead make use of decentralized oracle networks that can be used to get volume-weighted average prices (VWAP) or time-weighted average prices (TWAP) on-chain for centralized and decentralized exchanges. This way, the data feeds will come from multiple data sources and price time frames, which will make the code much less susceptible to attacks and manipulation. It is important for developers to remove any oracle manipulation attack vectors in their smart contracts to prevent potential exploits.
#4: Set Proper Function Visibility Settings and Access Rights
It is important to set the visibility of functions correctly to ensure the security and integrity of the smart contract. The use of incorrect function visibility settings can cause serious security vulnerabilities, as it allows unintended users to manipulate the contract state and potentially steal funds or take control over important contract functions.
By setting the visibility of functions to private or internal, developers can restrict access to certain functions and ensure that only authorized parties can execute them. Private functions can only be called from within the contract itself, while internal functions can also be called from within contracts that inherit from the current contract. This allows developers to create more complex contracts with greater functionality, while still maintaining control over who can access certain functions.
Consider the function setAdmin() which allows anyone to set any address as a contract admin. Depending on the privileges granted to the admin address within the contract, this could potentially lead to lost funds and loss of control over the contract itself.
function setAdmin(address account) external {
admin[account] = true;
}
By setting the function visibility to internal, this can allow only certain contract functions to internally set certain users as admins.
function setAdmin(address account) internal {
admin[account] = true;
}
Access modifiers are an important security feature that can be used to dictate who has access to specific functions or variables within the contract. These modifiers are used to restrict the visibility of certain functions or variables to specific roles or addresses, preventing unauthorized access or manipulation of the contract's state by malicious actors. For example, a contract may have a function that only the contract owner can call, or a variable that can only be accessed by a specific set of addresses.
By leaving the visibility modifier to external and setting the the access modifier to onlyOwner, access to the setAdmin function will be restricted to only the contract owner address. This will prevent malicious external parties from taking control of certain privileged functions.
function setAdmin(address account) external onlyOwner {
admin[account] = true;
}
Proper use of visibility and restriction modifiers can make contract management much easier. Common attacks such as reentrancy attacks, where an attacker repeatedly calls a function to manipulate the contract state, or front-running attacks, where an attacker monitors pending transactions and manipulates the contract state before a legitimate transaction is executed, can be reduced as well. By using these features appropriately, developers can enhance the security and reliability of their contracts, reduce the risk of unwanted changes or unauthorized access, and improve the overall quality and maintainability of their code.
#5: Beware of Contract Upgradability Issues
Carefully consider the contract design when deciding whether to include contract upgradability. Contract upgradability refers to the ability to modify or update the logic of a smart contract after it has been deployed on the blockchain. While upgradability can offer many advantages, such as fixing bugs, improving efficiency, or adding new features, it also introduces some risks, such as introducing new vulnerabilities, increasing complexity, or causing unintended consequences. The fact that contracts can be upgraded also raises trust issues because the proxy admin can upgrade the contract without consensus from the community. Therefore, it is important to carefully weigh the benefits and drawbacks of upgradability and determine whether it is truly necessary for a given use case. In some cases, it may be more appropriate to design a contract that is intended to be immutable and secure from the outset, rather than relying on the ability to modify it later.
When it comes to contract upgradability, there are several important practices to follow. First and foremost, it is crucial not to modify the proxy library. The complexity of proxy contract libraries, particularly with regards to storage management and upgrade mechanisms, means that even minor mistakes in modification can significantly impact the functioning of the proxy and logic contract. In fact, many high-severity proxy-related bugs discovered during audits have been caused by improperly modified proxy libraries.
Another key practice for contract upgradability is to include a storage gap in base contracts. Logic contracts must have a storage gap built into the contract code to account for new state variables that may be introduced when a new logic implementation is deployed. It is important to update the size of the gap accordingly after adding new state variables. This practice ensures that future upgrades can be made smoothly and without complications.
/**
* @dev This empty space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[45] private __gap;
Finally, it is essential to avoid using selfdestruct() or performing delegatecall()/ call() to untrusted contracts. An attacker can exploit the use of these functions to destroy the logic implementation or execute custom logic. To prevent this, it is important to verify user input and not allow the contract to perform delegatecall()/ call() to untrusted contracts. Additionally, using delegatecall() in the logic contract is not recommended, as managing the storage layout in multiple contracts can be difficult. By following these practices, developers can minimize the risk of vulnerabilities in their contract upgradability implementations.
#6: Protect Against Front-running
Front-running has been a persistent problem where users are able to exploit the delay between the submission of a transaction and its confirmation on the blockchain to make a profit. This delay is caused by the use of a mempool, a temporary storage area for unconfirmed transactions that have been broadcast to the network. All nodes in the network maintain a mempool, allowing anyone to see the pending transactions and potentially intercept and profit from them. The mempool also provides an opportunity for miners to re-order transactions to maximize their profits, creating what is known as Miner (or Maximal) Extractable Value (MEV).
The following is an example of an auction bid function that is susceptible to front-running.
function vote(uint256 bidAmount) public {
require(bidAmount > highestBid);
highestBid = bidAmount;
highestBidder = msg.sender;
}
This function allows users to place bids on an auction, but it can be vulnerable to front-running attacks. Suppose a malicious user monitors the blockchain and sees that another user has submitted a high bid. The malicious user can then quickly submit a higher bid, which will be processed first and ultimately win the auction.
In the following version, users submit encrypted bids that are stored in a mapping. The bid amounts are obscured until the end of the bidding period.
function vote(bytes32 encryptedBid) public {
encryptedBids[msg.sender] = encryptedBid;
}
function revealBid(uint256 bidAmount) public {
require(auctionEnded);
bytes32 encryptedBid = encryptedBids[msg.sender];
require(encryptedBid != 0);
require(keccak256(abi.encodePacked(bidAmount, secret)) == encryptedBid);
require(bidAmount > highestBid);
highestBid = bidAmount;
highestBidder = msg.sender;
}
At the end of the bidding period, users can reveal their bids by submitting the original bid amount along with a secret value. The contract verifies that the hash of the bid amount and secret matches the stored encrypted bid, ensuring that the bid was submitted before the end of the bidding period. If the bid is higher than the current highest bid, it becomes the new highest bid. By obscuring the bid amount until the end of the bidding period, this function mitigates against front-running attacks.
Front-running and MEV have become a major concern in the blockchain community, and various solutions, such as encrypted transactions and Fair Sequencing Services (FSS), have been proposed to address these issues. Encrypted transactions can help prevent front-running by hiding transaction details from other users until the transaction is executed on the blockchain. FSS, on the other hand, can help reduce the impact of front-running and MEV by enabling secure off-chain ordering of transactions.
#7: Develop a Proactive Security Response Plan
Developing a clear and comprehensive response plan is crucial for dealing with security incidents. This plan should be regularly reviewed, updated, and tested for effectiveness. In the event of an incident, time is of the essence, so the plan should include clear steps for identifying, containing, and mitigating the situation.
A communication plan should be in place to keep stakeholders informed. Regular data backups are also important to prevent data loss. The plan should outline the recovery process for restoring data and systems to their previous state. Team members should be trained on the plan to ensure everyone understands their roles and responsibilities.
A well-prepared response plan can minimize the impact of an incident and maintain trust with users and stakeholders.
#8: Conduct Routine Audits
Routine code audits are essential for maintaining the security of your application. Working with reputable auditors who specialize in smart contract security is an essential step in the development process. The auditor will examine the code for vulnerabilities and provide recommendations for improving overall security. Prioritizing and addressing identified issues and maintaining open communication with the auditor are crucial to achieving meaningful improvements in security.
Communication with the auditor is also important, as they can help to explain their findings and provide guidance on how to address any vulnerabilities. By working together, the audit results will produce meaningful improvements to the security of the application.
Conducting routine audits is a key component of any comprehensive security strategy for BNB Chain developers. Proactively identifying and addressing vulnerabilities in the code can minimize the risk of security breaches and ensure the safety of users' funds and assets.
#9: Make Use of Bounty Programs
Expanding on the previous tip, using a bounty program is a great way to incentivize the community to search for and report security vulnerabilities in your code. By offering a reward, such as tokens or other incentives, you can encourage skilled individuals to examine your code and report any potential issues they find.
It's important to have a well-defined and transparent program with clear rules and guidelines for what types of vulnerabilities are eligible for rewards and how they will be evaluated. Reputable third-party bug bounty programs can help ensure that the program is run smoothly and that rewards are fairly distributed. Having a diverse group of bounty hunters is also important, as different people will have different areas of expertise and can focus on finding issues that others may miss.
Finally, once vulnerabilities have been reported, it's crucial to act quickly and effectively to address them. The bounty program can be a useful tool for identifying vulnerabilities, but it's up to the development team to actually fix them and improve the security of their application.
#10: Educate Users About Security Best Practices
Educating Web3 users is a crucial step in building a secure ecosystem. Keeping your customers safe helps keep your platform safe. Users should be educated on best practices for protecting their accounts and sensitive information. One of the most important aspects of user education is teaching users to avoid phishing scams. Phishing scams are designed to trick users into revealing their private keys or passwords by impersonating legitimate websites or services. Users should be advised to always double-check the URL of the website they are using and to never enter their private keys or passwords on any website that they do not trust.
Strong passwords are another essential part of personal security. Users should be encouraged to use unique and complex passwords for each of their accounts, and to avoid reusing passwords across different services. Passwords should also be stored securely, using a password manager or other secure storage mechanism.
Finally, users should be reminded to protect their private keys. Private keys are the equivalent of a user's password and should be kept secret at all times. Users should avoid sharing their private keys with anyone and store them in a secure location. They should also be advised to never enter their private keys on any website or service that they do not trust.
Conclusion
Developers building smart contracts and dApps on BNB Chain must take a comprehensive approach to security to ensure the safety of their users' funds and assets. This includes having multiple layers of security practices to ensure that the code and processes are secure and free of vulnerabilities. It is more important to be proactive in handling security vulnerabilities rather than reactive by creating a plan when exploits do occur and by properly educating all relevant users and stakeholders in security best practices. All such measures can help significantly reduce the risk of security breaches and hacks.
Posted on May 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.