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实现是健全的(尽管在承诺更困难的路线之前,你可能想再次确认这一点),这给我们留下了铸造函数。
|
|
现在的目标是以某种方式调整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编码如下:
|
|
为了解析该数据,ABI解码器运行以下伪代码:
|
|
到目前为止一切顺利。让我们看看malloc是如何实现的,再次使用伪代码:
|
|
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见!