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 合约正在尝试通过 SwappableTokenTwoapprove 函数,批准自己(DexTwo 作为 spender)来花费 DexTwo 合约拥有的 to 代币。

根据 SwappableTokenTwoapprove 函数逻辑,这个操作将会被 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 代币,并将其命名为 token1token2 的副本。
  2. 将代币“充值”给 DEX: 通过 add_liquidity 函数,我们将少量(比如 1 个)我们自己创建的 token1token2 添加到 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 合约的实例,以及 token1token2 的地址。
    • 重点来了: 部署两个新的 SwappableTokenTwo 代币合约,分别作为我们攻击用的 token1token2关键在于,这两个新代币合约的 _dex 参数,我们传入的应该是 DexTwo 合约的地址。
    • 将这两个新代币中的少量代币(例如 1 个)transferDexTwo 合约中。
    • 这是最关键的一步: 对我们新部署的这两个代币,调用 approve(address(target), 1)。这里的 spenderaddress(target)(即 DexTwo 合约),但 ownermsg.sender(即我们部署的 Hack 合约)。这与 SwappableTokenTwo 合约中 approve(address owner, address spender, uint256 amount) 的函数签名不同。Hack.solconstructor 中,代码是 sttw1.approve(address(target), 1); 并且 sttw1SwappableTokenTwo 的实例,它会自动调用 SwappableTokenTwoapprove 方法。 并且 SwappableTokenTwoapprove 方法是 function approve(address owner, address spender, uint256 amount) public,我们调用 sttw1.approve(address(target), 1); 时,ownermsg.sender (Hack合约),spenderaddress(target) (DexTwo合约)。然而,DexTwo 合约中的 approve 函数是 function approve(address spender, uint256 amount),这个函数是直接调用 SwappableTokenTwoapprove 方法,而 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.solconstructor 中,我们部署了 SwappableTokenTwo 实例 sttw1sttw2,并且将它们转给了 DexTwo 合约。然后,我们调用了 sttw1.approve(address(target), 1);。 这实际上是在调用 SwappableTokenTwo 合约中的 approve(address owner, address spender, uint256 amount) 方法。 在这里,ownermsg.sender(即 Hack 合约),spenderaddress(target)(即 DexTwo 合约)。

    这个 approve 调用是成功的,因为它并没有违反 require(owner != _dex, "InvalidApprover"); 这个规则,因为 ownerHack 合约,而不是 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 代币。 由于 t1DexTwo 合约的 token1,它很可能也是一个 SwappableTokenTwo 合约。 DexTwo 合约尝试批准自己(address(this))作为 owner 时,就会触发 InvalidApprover 错误。
    5. 然而,由于 swap 函数没有对 approve 调用失败进行回滚,程序会继续执行。
    6. IERC20(to).transferFrom(address(this), msg.sender, swapAmount);这里就出现了问题! DexTwo 合约尝试从自己(address(this))转移 swapAmountt1 代币给 msg.senderHack 合约)。但是,DexTwo 合约并没有成功获得 t1 代币的 approve 授权(在步骤 4 中失败了)!

    这个攻击的精髓在于:

    • 我们创建自己的 SwappableTokenTwo,并成功地让 DexTwo 合约(通过 swap 方法)接收了我们代币的一部分。
    • DexTwo 合约试图兑换 t1 代币时,DexTwo 合约自身作为 owner 去调用 SwappableTokenTwoapprove 函数,触发了 InvalidApprover 错误,导致 DexTwo 合约无法获得 t1 代币的支配权。
    • 但是, DexTwo 合约中的 swap 方法并没有在 approve 失败时回滚。这意味着,DexTwo 合约中本来属于 token1 的库存,并没有被 DexTwo 合约成功地“锁住”或者“批准”用于兑换。

    根据 getSwapAmount 的计算方式:((amount * IERC20(to).balanceOf(address(this))) / IERC20(from).balanceOf(address(this)));DexTwo 合约的 token1balanceOf(address(this)) 很大时,getSwapAmount 会计算出一个很大的 swapAmount。 然后在 target.swap(address(sttw1), address(t1), 1); 中,DexTwo 合约试图从 t1DexTwo 合约的 token1)中转移 swapAmountt1 代币给 Hack 合约。 由于 DexTwo 合约的 approve 失败,它实际上并没有从 t1 的库存中扣除代币。

    换句话说,DexTwo 合约将我们发送给它的 sttw1 代币,用在了计算 swapAmount 上,但它在实际转出 t1 代币时,由于 approve 失败,并没有真正从 t1 的储备中扣除。

    最终,target.swap(address(sttw1), address(t1), 1); 操作,相当于将 DexTwo 合约中大量的 t1 代币,通过这个“无效”的 swap 操作,转移到了 Hack 合约中。

    同样的操作,再对 token2 进行一遍,就能榨干 DexTwo 合约所有的 token1token2

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

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

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

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

Built with AiAda