Paradigm CTF 2021 是21年2月举行一场夺旗比赛,共 17 题,为时两天。主要出题人为 samczsun,作为知名白帽于去年加入 Paradigm 任职研究合伙人,并且有多位业界知名白帽技术人员参赛。 本文将罗列本次比赛的方案,以供学习分享:我的解决方案Github。
因为比赛已经结束,官方环境使用的docker来保证各选手环境隔离,公平竞争。但是我这里通过本地的brownie环境进行答题,详细部署可以参考官方文档。
用于习惯本CTF的环境,只需要部署完合约(可以在dev环境),然后执行对应的solved函数即可:

为了方便本地调试,将WETH9合约拷贝并本地部署。
Smarter Contracts Inc. 自豪地发布我们的去中心化银行。存入任何与 ERC20 兼容的代币,并知道它将安全地存储在我们不可破解的合约中。
主要思想是触发accounts[msg.sender].length变量的下溢并将其设置为2^256 - 1. 作为基本规则:
每当Solidity存储存在一个下溢存的动态数组并且有一个允许写入任何该数组索引的函数时,您就可以对合约的存储进行任意写入访问。
这是由于 EVM 处理动态大小变量的存储布局的方式。一旦我们可以在任何地方写入,我们就可以通过setAccountName函数写入余额的存储槽来增加余额。

现在我们需要梳理合约的存储分析:

则我们的 WETH 余额存储为accounts[our_address][accountId].balances[WETH]对应于以下固定存储槽:
balanceSlot := keccak( WETH_Token . [keccak(keccak(our_addr . 2)) + 3 * accountId + 2] )
因为需要利用setAccountName函数将accounts[msg.sender][accountId].accountName的位置与我们自己的WETH余额位置相同,则可以得出:
accountStructSlot(accountId) == balanceSlot
<=> keccak(keccak(our_addr . 2)) + 3 * accountId == balanceSlot
<=> accountId = [balanceSlot - keccak(keccak(our_addr . 2))] / 3
请注意,我们在这里进行整数除法,并且我们依赖于可被 3 整除的项。规避此问题的最简单方法是简单地选择不同的攻击者合约地址 (
our_addr),直到我们最终得到该值可被 3 整除的地址3.
我们现在知道setAccountName(accountId, value)一旦我们拥有任意写访问权限时调用函数的参数。
请注意如何使用solidity版本4,并.length在两个函数中直接递减:withdrawToken和closeLastAccount. 代币合约参数未选中,因此我们可以将它们用于重入。这里的重入很复杂,因为这两个函数都只会递减 iflength > 0和account.uniqueTokens == 0。
我通过一个四级深度balanceOf的重入攻击解决了这个问题,总是在这些函数的第一个递归到攻击者合约:

目标是窃取合约的 ETH 资金。您可以通过为每个条目支付固定的 1 ETH 费用来创建任何价值的存款(条目) 。之后,使用该功能支付每笔存款的实际价值。
题目要求是拿走Bouncer合约的所有ETH,故我们关注下ETH流出函数:claimFees和redeem,由于claimFees要求是owner,我们可以先看下redeem

注意这里,由于是solidity 0.8.0, 其加减乘除法都实现了openzepplin的safemath库,故此函数的实际要求是: require(tokens[msg.sender][address(token)] >= amount);
从合约可以看出正常的流程是:
enter(ETH,1 ether)
-- new Entry{amount=1 ether, time=now, token=ETH}
convert(user, id)
-- 拿到entry = entries[user][id]
-- 进行convert里面的三个验证:timestamp, allowance, msg.sender=user
--> proofOfOwnership(token,user,amount)
--验证 msg.value == amount
-- token[user][token] += amount
redeem(token, amount)
-- token[user][token] > amount
--> payout(token,msg.sender,amount)
正常流程中,应该是每一次convert都需要验证msg.value==amount。但是如果改成convertmany,则只需要一次满足即可,其流程变为:
enter(ETH, x ether) <msg.value=1 ether>
enter(ETH, x ether) <msg.value=1 ether>
convertmany(user, ids) <msg.value=x ether>
--> convert(user, ids[0])
--> proofOfOwnership(token, user, amount)
-- require(msg.value == amount) 满足
<token[user][ETH] = x>
--> convert(user, ids[1])
--> proofOfOwnership(token, user, amount)
-- require(msg.value == amount) 满足
<token[user][ETH] = 2x>
redeem(ETH, 2x ether)
--> payout(ETH, msg.sender, 2x ether)
这样我们只需要执行:

就可以将最后69个ether全部赎回
这是一个借贷平台接受 WETH 作为抵押来借出AMT代币。
它使用 Uniswap 对的现货价格,这意味着我们可以简单地扭曲价格预言机储备并清算挑战者的头寸。每个账户以 5000 ETH 开始,所以我们可以只交易 Uniswap 对进行交易:

此挑战需要Compound协议一些知识。
这个挑战包括一个农民合同,允许任何人调用行动来索取代币并代表farmer代表 Uniswap 出售它们。如果农民最终获得的代币少于挑战开始时以 Uniswap 的市场价格收获和回收所有代币,我们将赢得这一挑战。
这是三明治攻击的主要例子。要点是farmer进行Comp -> WETH -> DAI交易,三明治攻击将通过交易Comp -> WETH或WETH -> DAI. 这导致farmer想要购买的代币价格上涨。

钱包合约只允许调用某些预先批准的功能。提供的账户总是以 5000 ETH 开头,如果Setup合约有 50 (W)ETH,挑战就解决了。

