Paradigm CTF 2021 Swap挑战深度解析

本文深入分析了Paradigm CTF 2021中最难的swap挑战,通过内存别名攻击利用Solidity ABI解码漏洞,实现免费获取LP代币并耗尽AMM资金池的完整技术方案。

Paradigm CTF 2021 - Swap挑战解析

当你排除了所有不可能的情况,剩下的不管多么难以置信,都一定是真相 - 夏洛克·福尔摩斯

Paradigm CTF 2021于2月初举行,玩家们在比赛期间解决了除两个挑战外的所有题目(其中一个在几天后也被解决)。然而,第二个挑战在近两个月内一直未被解决,直到几天前。

今天,我们将以引导式教程的形式来研究swap挑战。每个部分都会在结尾给出小提示,以防你卡住。如果你还没有看过挑战文件,请先在此查看并熟悉代码。

挑战概述

这个挑战实现了一个虚构的稳定币AMM。用户可以执行AMM的所有预期操作:交换资产和铸造/销毁/转移LP份额。合约初始化为价值100以太坊的四种稳定币(DAI、TUSD、USDC、USDT),获胜条件是将AMM的TVL减少到起始值的1%。

最初,你可能会假设swap函数存在漏洞,但很容易以合理的置信度确认该函数按预期行为,不会给你任何免费的稳定币。

你可能也尝试从设置合约本身窃取LP代币,但ERC20实现似乎是正确的,因此这也是不可能的。

你可能会尝试铸造和销毁LP代币,但会发现不变量似乎得到正确维护,你无法铸造超过应得数量的LP代币,也无法销毁超过拥有的LP代币。

最后,为了真正说明这一点,所有函数都标记为nonReentrant。

此时,挑战看起来相当无望,因此在比赛条件下,完全有理由放弃它并转向其他问题。然而,既然我们拥有全世界的时间,我们该如何解决这个问题呢?

敏锐的观察者可能会注意到这个挑战的获胜条件略有不同。在所有其他挑战中,总是需要耗尽合约的所有资产。然而,在这个挑战中,你只需要耗尽一定比例。这可能是什么原因呢?

一切都是有意为之

我在艰难中学到的教训之一是,CTF中的一切,无论多么无害,都是有意的。从这个意义上说,获胜条件是对解决方案所需内容的微妙提示。你不需要耗尽所有资产来获胜的事实应该强烈表明,实际上不可能耗尽所有资产。

但这对我们意味着什么?从第一原则出发,我们可以通过以下逻辑链重置假设:

  • 我们需要从资金池中移除(部分但不是全部)资产
  • 资产离开资金池的唯一方式是通过交换或销毁
  • 交换似乎正确实现,无法窃取资产
  • 销毁似乎也正确实现,无法窃取资产
  • 然而,交换是单步过程,而销毁是两步过程,其中第一步是以某种方式获取LP代币。销毁操作本身没有说明LP代币的来源,因此必须有一种获取免费LP代币的方法

微妙的提示也强化了这一理论。假设我们无法从设置合约窃取LP代币,我们将永远无法销毁100%的所有LP代币,因此永远无法耗尽整个合约。

在假设必须有一种获取免费LP代币的方法的前提下,你能想出解决方案吗?

无论多么不可能

获取LP代币只有两种方式。第一种是铸造它们,第二种是被转移它们。我们已经确定ERC20实现是健全的(尽管在承诺更困难的路线之前,你可能想再次确认这一点),这给我们留下了铸造函数。

 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
struct MintVars {
    uint totalSupply;
    uint totalBalanceNorm;
    uint totalInNorm;
    uint amountToMint;
    
    ERC20Like token;
    uint has;
    uint preBalance;
    uint postBalance;
    uint deposited;
}

function mint(uint[] memory amounts) public nonReentrant returns (uint) {
    MintVars memory v;
    v.totalSupply = supply;
    
    for (uint i = 0; i < underlying.length; i++) {
        v.token = underlying[i];
        
        v.preBalance = v.token.balanceOf(address(this));
        
        v.has = v.token.balanceOf(msg.sender);
        if (amounts[i] > v.has) amounts[i] = v.has;
        
        v.token.transferFrom(msg.sender, address(this), amounts[i]);
        
        v.postBalance = v.token.balanceOf(address(this));
        
        v.deposited = v.postBalance - v.preBalance;

        v.totalBalanceNorm += scaleFrom(v.token, v.preBalance);
        v.totalInNorm += scaleFrom(v.token, v.deposited);
    }
    
    if (v.totalSupply == 0) {
        v.amountToMint = v.totalInNorm;
    } else {
        v.amountToMint = v.totalInNorm * v.totalSupply / v.totalBalanceNorm;
    }
    
    supply += v.amountToMint;
    balances[msg.sender] += v.amountToMint;
    
    return v.amountToMint;
}

现在的目标是以某种方式调整v.amountToMint,使我们铸造的LP代币远远超过应得数量。我们可以开始再次审查函数本身,但这次要仔细检查。函数首先构建一个对象来表示局部变量。然后,对于每个代币,我们记录AMM的当前余额以及发送者的余额。如果要存入的金额大于发送者的余额,则更新请求的金额。我们执行转移,再次记录当前余额,然后适当更新总存入计数器。

没有立即明显的发现,因此我们必须开始排除不可能的情况,只留下可能的情况。不幸的是,有时我们可能没有足够的知识来立即区分不可能和可能,因此我们需要保持开放的心态。

我们可以再次使用以下逻辑链从第一原则重置假设:

  • 我们显然需要以某种方式改变v.amountToMint,使值大于应有的值
  • 计算铸造数量的方程正确实现
  • 因此,必须有另一种方式直接篡改v.amountToMint或篡改它所依赖的值之一

你看到任何这样做的方法吗,即使起初看起来不可能或不太可能?

真名具有力量

注意v.amountToMint是MintVars memory v上的一个字段。这意味着任何时候我们写入v.amountToMint,我们实际上是在写入内存。与栈上的数据不同(回想一下EVM是基于栈的虚拟机),内存中的数据可以别名化,意味着相同的数据可以通过多个符号名称访问。

恰好我们在作用域中有两个指向内存的指针。第一个是MintVars memory v,第二个是uint[] memory amounts。如果我们能以某种方式使后者指向与前者相同的内存地址,那么我们可以通过更新一个来触发未定义行为。

为了理解amounts变量来自哪里,我们首先简要绕道到Solidity ABI的领域。所有交易都只是数据blob,ABI的工作是定义一种标准方式来将该blob解析为结构化信息。在合约代码执行之前,Solidity插入一些逻辑,将交易数据解码为代码期望的类型。只有这样,合约才开始执行。

在mint(uint256[])的情况下,ABI解码器将交易数据解码为单个uint256数组。我们能否以某种方式欺骗ABI解码器,使解码后的数组与后面构造的MintVars对象别名?

环游世界

根据Solidity文档,数组的ABI编码如下:

1
2
3
4
5
6
7
8
9
<- 32 bytes ->

OOOOO....OOOOO [offset to array start]
    [....]
LLLLL....LLLLL [length in words]
DDDDD....DDDDD [data #1]
DDDDD....DDDDD [data #2]
    [....]
DDDDD....DDDDD [data #L]

为了解析该数据,ABI解码器运行以下伪代码:

1
2
3
4
5
6
7
function decodeArrayAt(uint arg) private returns (bytes memory) {
    uint offset = read(0x04+0x20*arg);
    uint length = read(0x04+offset);
    bytes memory data = malloc(length);
    memcpy(data, 0x04 + offset + 0x20, length);
    return data;
}

到目前为止一切顺利。让我们看看malloc是如何实现的,再次使用伪代码:

1
2
3
4
5
6
7
8
9
function malloc(uint size) private returns (bytes memory) {
    bytes memory buf;
    assembly {
        buf := mload(0x40)
        mstore(0x40, add(add(buf, size), 0x20))
        mstore(buf, size)
    }
    return buf;
}

Solidity使用所谓的线性内存分配器(或基于区域的分配器)。这只是意味着Solidity将沿着总可用内存块线性分配新内存。为了分配新块,Solidity读取存储在0x40的自由内存指针以确定下一个空闲地址的位置,并将其向前移动以反映刚刚分配了新内存块的事实。

注意没有检查确保请求的内存量不会过大。这意味着如果一个人要分配特定数量的内存,那么自由内存指针可能会溢出并开始重新分配正在使用的内存。在这种情况下,两次对伪malloc的调用可能返回相互别名的指针。

幸运的是,我们可以通过简单地更改数组的ABI编码表示来控制分配器将分配的内存大小。这使我们能够导致分配器在amounts数组之上分配v。

你能想出解决这个挑战的最后一步吗?

技术难点

因为v和amounts现在相互别名,两者将共享相同的值,更新一个将更新另一个。这意味着虽然你可以通过写入amounts来操纵v,但反之亦然。

因此,如果你只是尝试通过使用一些自定义值调用mint来强制一些初始值到v中,你可能会发现你的数据不断被对v.totalSupply、v.totalBalanceNorm和v.totalInNorm的写入破坏。这意味着需要一些仔细的计划,以确保你的值在所有四次循环迭代中保持一致。

此外,v.amountToMint在四次迭代后更新,这意味着即使我们尝试直接写入amounts[3],它最终也会被正确计算的值覆盖。这意味着我们需要使v.totalInNorm或v.totalSupply非常大,或者使v.totalBalanceNorm非常小。

为此,我们首先交换出AMM中除DAI外的所有稳定币。这意味着v.totalBalanceNorm在第一次迭代后不会增加,因为AMM没有余额。这只是让我们以后的生活更轻松一些。

接下来,我们将购买正确数量的DAI/TUSD/USDC,这样当执行amounts[i] = v.has时,我们将把所需的值写入v。DAI的余额将被写入v.totalSupply,因此我们希望将其设置为大数字,如10000e18。TUSD的余额将被写入v.totalBalanceNorm,因此我们将其更新为1。最后,USDC的余额将被写入v.totalInNorm,因此我们也将设置为10000e18。操纵USDT余额没有意义,因为该值无论如何都会被破坏。

最后,我们只需要组装我们的payload并发送交易,给我们努力争取的标志:PCTF{hon3y_1_Ov3rFLOw3d_7h3_M3MOry}。

你可以在这里找到官方解决方案。

结论

这是CTF中最复杂的挑战,原因很充分 - 它利用了一个大多数人没有听说过且很少有人积极思考的漏洞。它还挑战了关于Solidity源代码真正做什么的基本假设。然而,我认为这是一个很好的练习,可以从第一原则解决问题,消除假设,并探索整个搜索空间。希望你喜欢这篇解析,我们明年在Paradigm CTF 2022见!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计