Appearance
DexTwo 挑战:精妙的漏洞,一网打尽 DEX 资产!
EtherNaut 的 DexTwo 关卡,顾名思义,将带我们深入探索一个经过精心改造的 DEX 合约。这次的任务目标明确:将 DexTwo 合约中所有的 token1 和 token2 的余额全部“掏空”!
你将像往常一样,从 10 个 token1 和 10 个 token2 开始。而 DexTwo 合约本身,则拥有着 100 个 token1 和 100 个 token2 的初始储备。
看似简单的目标,背后隐藏着怎样的玄机?
核心的突破点,在于 DexTwo 合约的 swap 方法。
我们先来看看 DexTwo 合约中的 swap 方法:
solidity
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);
}
以及 getSwapAmount 方法:
solidity
function getSwapAmount(address from, address to, uint256 amount) public view returns (uint256) {
return ((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));
}
漏洞解析:approve 的陷阱
在 DexTwo 合约中,SwappableTokenTwo 这个特殊的代币合约,引入了一个经过修改的 approve 函数:
solidity
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);
}
}
注意这个 require(owner != _dex, "InvalidApprover");。 这个 approve 函数 不允许 DEX 合约 (_dex) 作为 owner 来调用,也就是说,DEX 合约无法代表自己(owner)来批准自己的代币给其他地址(spender)使用。
然而,在 DexTwo.swap 函数中,有这样一行代码:
solidity
IERC20(to).approve(address(this), swapAmount);
这里的 to 指向的是代币合约,而 address(this) 则是 DexTwo 合约本身。这意味着,DexTwo 合约正在尝试通过 SwappableTokenTwo 的 approve 函数,批准自己(DexTwo 作为 spender)来花费 DexTwo 合约拥有的 to 代币。
根据 SwappableTokenTwo 的 approve 函数逻辑,这个操作将会被 require(owner != _dex, "InvalidApprover"); 阻止!
那么,这和我们如何掏空 DEX 资产有什么关系呢?
问题的关键在于,DexTwo.swap 方法首先会执行 IERC20(from).transferFrom(msg.sender, address(this), amount);,将你的代币转移到 DEX 合约。然后,它才会尝试进行 IERC20(to).approve(...)。
如果 approve 调用失败,swap 函数会继续执行 transferFrom,但此时 DexTwo 合约并没有足够多的 to 代币,因为它在 approve 阶段就卡住了,并没有真正获得 to 代币的支配权。
攻击思路:利用 approve 的失败
既然 DexTwo 合约无法自己批准自己来花费代币,那么我们就可以利用这一点来操纵代币的流动。
- 创建自己的代币: 我们可以创建一个新的
SwappableTokenTwo代币,并将其命名为token1和token2的副本。 - 将代币“充值”给 DEX: 通过
add_liquidity函数,我们将少量(比如 1 个)我们自己创建的token1和token2添加到DexTwo合约中。 - 执行一次“无效”的 Swap:
- 让
DexTwo合约尝试从我们创建的token1中进行一次swap,目标为token2。 - 由于
DexTwo合约调用SwappableTokenTwo(token1).approve(address(this), swapAmount)时会触发InvalidApprover错误。 - 但是,
DexTwo合约在swap方法中 先 执行了IERC20(from).transferFrom(msg.sender, address(this), amount);,这意味着,我们创建的token1会被转移到DexTwo合约中。 - 紧接着,
IERC20(to).approve(address(this), swapAmount);会失败,但DexTwo合约已经拥有了我们转过去的token1。
- 让
这种机制创造了一个有趣的“漏洞”: DexTwo 合约在尝试执行 swap 时,会先将你的代币转移给自己,但其自身对代币的 approve 操作却会因为 SwappableTokenTwo 的限制而失败。然而,代币已经被转移到 DEX 了,但 DEX 却没能“成功”地获得这些代币的支配权。
最终的攻击流程:
部署攻击合约 (
Hack.sol):- 在构造函数中,获取
DexTwo合约的实例,以及token1和token2的地址。 - 重点来了: 部署两个新的
SwappableTokenTwo代币合约,分别作为我们攻击用的token1和token2。关键在于,这两个新代币合约的_dex参数,我们传入的应该是DexTwo合约的地址。 - 将这两个新代币中的少量代币(例如 1 个)
transfer到DexTwo合约中。 - 这是最关键的一步: 对我们新部署的这两个代币,调用
approve(address(target), 1)。这里的spender是address(target)(即DexTwo合约),但owner是msg.sender(即我们部署的Hack合约)。这与SwappableTokenTwo合约中approve(address owner, address spender, uint256 amount)的函数签名不同。在Hack.sol的constructor中,代码是sttw1.approve(address(target), 1);并且sttw1是SwappableTokenTwo的实例,它会自动调用SwappableTokenTwo的approve方法。 并且SwappableTokenTwo的approve方法是function approve(address owner, address spender, uint256 amount) public,我们调用sttw1.approve(address(target), 1);时,owner是msg.sender(Hack合约),spender是address(target)(DexTwo合约)。然而,DexTwo合约中的approve函数是function approve(address spender, uint256 amount),这个函数是直接调用SwappableTokenTwo的approve方法,而SwappableTokenTwo中的approve方法是function approve(address owner, address spender, uint256 amount)。
这里需要特别注意的是
Hack.sol中的constructor部分:solidityconstructor(address _target) { target = IDexTwo(_target); t1 = IERC20(target.token1()); t2 = IERC20(target.token2()); // 部署我们自己的 SwappableTokenTwo 合约 SwappableTokenTwo sttw1 = new SwappableTokenTwo(address(target), "Token Whatelse 1", "TKNW1", 10000); SwappableTokenTwo sttw2 = new SwappableTokenTwo(address(target), "Token Whatelse 2", "TKNW2", 10000); // 将我们自己的代币转入 DexTwo 合约 bool success_w1 = sttw1.transfer(address(target), 1); require(success_w1, "Transfer fail!"); bool success_w2 = sttw2.transfer(address(target), 1); require(success_w2, "Transfer fail!"); // **这是一个关键点:** 调用我们自己代币合约的 approve 方法 // 这里的 sttw1.approve(address(target), 1); 实际上是调用 SwappableTokenTwo 的 approve(address owner, address spender, uint256 amount) // 其中 owner = msg.sender (Hack合约),spender = address(target) (DexTwo合约) bool success_w1_a = sttw1.approve(address(target), 1); require(success_w1_a, "Approve fail!"); bool success_w2_a = sttw2.approve(address(target), 1); require(success_w2_a, "Approve fail!"); // 尝试进行 swap,利用 DexTwo 合约的 swap 方法 target.swap(address(sttw1), address(t1), 1); // sttw1 是我们的代币,t1 是 DexTwo 合约的 token1 target.swap(address(sttw2), address(t2), 1); // sttw2 是我们的代币,t2 是 DexTwo 合约的 token2 }在
Hack.sol的constructor中,我们部署了SwappableTokenTwo实例sttw1和sttw2,并且将它们转给了DexTwo合约。然后,我们调用了sttw1.approve(address(target), 1);。 这实际上是在调用SwappableTokenTwo合约中的approve(address owner, address spender, uint256 amount)方法。 在这里,owner是msg.sender(即Hack合约),spender是address(target)(即DexTwo合约)。这个
approve调用是成功的,因为它并没有违反require(owner != _dex, "InvalidApprover");这个规则,因为owner是Hack合约,而不是DexTwo合约。紧接着,
target.swap(address(sttw1), address(t1), 1);开始执行:require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");:sttw1的余额足够(我们刚才转了 1 个)。uint256 swapAmount = getSwapAmount(from, to, amount);:计算 swap 数量。IERC20(from).transferFrom(msg.sender, address(this), amount);:将我们部署的sttw1代币(1 个)转移到DexTwo合约。IERC20(to).approve(address(this), swapAmount);:尝试批准DexTwo合约(address(this))来花费t1代币。 由于t1是DexTwo合约的token1,它很可能也是一个SwappableTokenTwo合约。 当DexTwo合约尝试批准自己(address(this))作为owner时,就会触发InvalidApprover错误。- 然而,由于
swap函数没有对approve调用失败进行回滚,程序会继续执行。 IERC20(to).transferFrom(address(this), msg.sender, swapAmount);:这里就出现了问题!DexTwo合约尝试从自己(address(this))转移swapAmount的t1代币给msg.sender(Hack合约)。但是,DexTwo合约并没有成功获得t1代币的approve授权(在步骤 4 中失败了)!
这个攻击的精髓在于:
- 我们创建自己的
SwappableTokenTwo,并成功地让DexTwo合约(通过swap方法)接收了我们代币的一部分。 - 在
DexTwo合约试图兑换t1代币时,DexTwo合约自身作为owner去调用SwappableTokenTwo的approve函数,触发了InvalidApprover错误,导致DexTwo合约无法获得t1代币的支配权。 - 但是,
DexTwo合约中的swap方法并没有在approve失败时回滚。这意味着,DexTwo合约中本来属于token1的库存,并没有被DexTwo合约成功地“锁住”或者“批准”用于兑换。
根据
getSwapAmount的计算方式:((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));当DexTwo合约的token1的balanceOf(address(this))很大时,getSwapAmount会计算出一个很大的swapAmount。 然后在target.swap(address(sttw1), address(t1), 1);中,DexTwo合约试图从t1(DexTwo合约的 token1)中转移swapAmount的t1代币给Hack合约。 由于DexTwo合约的approve失败,它实际上并没有从t1的库存中扣除代币。换句话说,
DexTwo合约将我们发送给它的sttw1代币,用在了计算swapAmount上,但它在实际转出t1代币时,由于approve失败,并没有真正从t1的储备中扣除。最终,
target.swap(address(sttw1), address(t1), 1);操作,相当于将DexTwo合约中大量的t1代币,通过这个“无效”的swap操作,转移到了Hack合约中。同样的操作,再对
token2进行一遍,就能榨干DexTwo合约所有的token1和token2!- 在构造函数中,获取
执行
swap: 攻击合约调用DexTwo合约的swap方法,进行“无效”的交易,将DexTwo合约中的token1和token2转移出来。
这个 DexTwo 关卡,充分展示了:
- 合约逻辑的精妙性: 仅仅一个
approve函数的小修改,就能带来意想不到的连锁反应。 - 对 Solidity 细节的理解: 深入理解函数调用顺序、
require语句的作用以及approve的工作机制,是解决这类问题的关键。 - 攻击的艺术: 并非 brute-force,而是找到合约设计上的“缝隙”,进行巧妙的利用。
准备好你的攻击合约,去挑战 DexTwo,成为区块链世界的“资产收割机”吧!