Paradigm CTF 是区块链行业最顶级、知名度最高的针对智能合约黑客的在线竞赛,由 web3 顶级投资公司 Paradigm 组织,CTF 题目由 Sumczsun 和受到邀请的客座作者创造的多项挑战组成。每一项挑战的目标都是破解或通过攻击技术解决问题。
在比赛期间,参赛者将完成一系列软件谜题挑战。在挑战期结束前,参与者正确解决或获得最高分数的每个挑战都将获得分数。对于山丘之王挑战赛,将根据 Elo 评分系统进行评分。每个正确解决挑战的参与者将获得的分数要到挑战期结束后才能知道。
Salus 安全团队共解决了 13 项挑战,在 1011 支队伍中以 3645.60 的分数获得第九名,并受邀成为 Paradigm CTF 2024 的客座作者。在这篇博文中,我们将介绍我们在比赛期间解决的所有挑战。
已解决的挑战
Hello World
Black Sheep
100%
Dai++
DoDont
Grains of Sand
Suspicious Charity
Token Locker
Skill Based Game
Enterprise Blockchain
Dragon Tyrant
Hopping Into Place
Oven
1. Hello World
这个挑战的目标是确保目标地址的 ETH 余额至少比之前多 13.37 。
我们创建了两个合约:一个测试合约 SolveTest 和一个执行操作的合约 Solve。SolveTest 合约通过设置初始环境和执行测试攻击来验证挑战是否已解决。 Solve 合约通过 killMySelf() 函数中的 selfdestruct 操作将资金转移到目标地址,从而达到增加目标地址 ETH 余额的目的。
2. Black Sheep
这个挑战的目标是从 BANK 合约中提取所有的 ETH。漏洞存在于 WITHDRAW() 函数中,由于 CHECKSIG() 函数没有正确处理返回值,在某些情况下直接结束执行而未将任何值推入栈,使得返回值错误地被读取为 CHECKVALUE() 的执行结果。我们的解决方案是编写了一个 Solver 合约,利用 WITHDRAW() 函数的漏洞,通过确保 CHECKVALUE() 返回 0 ,使得 WITHDRAW() 函数成功执行并从 BANK 合约中提取了所有的 ETH。
漏洞分析
我们研究了 WITHDRAW() 函数,该函数会先依次执行 CHECKVALUE() 和 CHECKSIG() 函数,然后根据执行结果,将合约的所有 ETH 发送到 msg.sender。其中, CHECKSIG() 函数没有正确地处理函数返回值。该函数需要在结束函数执行前,将一个结果推入栈作为返回值。但在某些情况下,该函数直接结束执行而未将任何值推入栈,导致返回值错误地被读取为栈顶的第一个元素,即 CHECKVALUE() 函数的执行结果。由于 CHECKSIG() 函数的设计缺陷,即使签名验证失败,也可以通过确保 CHECKVALUE() 函数返回 0 来使 WITHDRAW() 函数成功执行。
在 CHECKSIG() 函数中,调用 WITHDRAW() 函数时使用输入参数(bytes 32, uint 8, bytes 32, bytes 32)来调用地址 0x 1 。这个合约是一个预编译合约,其功能是基于参数恢复公钥地址。这里有两个检查。第一个是检查签名是否有效。如果 staticcall 执行成功,意味着签名有效,所以输入参数的内容并不重要。公钥的正确性在第二个检查中。如果公钥地址不正确,它不会回退,而是直接跳到函数的结尾。这个函数有一个返回值,根据正常执行,需要在结束函数执行之前将一个结果推入栈作为返回值。
然而,如果执行直接结束,没有将任何值推入栈。这将导致返回值被错误地读取为栈顶的第一个元素,即 CHECKVALUE() 的执行结果。因此,只要 CHECKVALUE() 函数的执行结果返回 0 ,WITHDRAW() 函数就可以顺利执行,并成功将 10 ETH 发送到 msg.sender。
我们希望 CHECKVALUE() 函数的执行结果为 0 ,即栈顶元素为 0 。我们只需要满足 “0x 10 > callvalue” 来让调用操作失败。
解决方案
我们编写了 Solver 合约来从 Bank 合约中取款。Bank 合约中的 ETH 是通过 WITHDRAW() 函数中的 call 操作发送到 Solver 合约中的。具体的流程如下:
在 Solver 合约中的 solve() 函数中,调用 Bank 合约的 WITHDRAW() 函数发起取款操作。
在 WITHDRAW() 函数中,首先执行 CHECKVALUE() 函数。由于我们的 callvalue 是 5 wei(小于 0x 10),所以会跳转到 over 标签。
在 over 标签中,会将 callvalue * 2 (即 10 wei)发送给调用者(也就是 Solver 合约)。由于在 Solver 合约的 fallback 函数中,如果接收到的 Ether 数量等于 10 wei,那么就会回滚交易,所以 over 标签中的 call 操作会失败,CHECKVALUE() 函数返回 0 。
WITHDRAW() 函数继续执行,将 Bank 合约的全部余额发送给调用者(也就是 Solver 合约)。这是通过 selfbalance caller gas call 这行代码实现的,其中 selfbalance 是合约的余额,caller 是调用者的地址,gas call 是发起调用的操作。
如果这个 call 操作成功,那么 Bank 合约的全部余额就会被发送到 Solver 合约。如果这个操作失败,那么会直接跳转到 noauth 标签,执行 revert 操作回滚交易。
3. 100%
这个挑战的目标是让 SPLIT 和 _splitsById[ 0 ].wallet 的 ETH 余额都必须为 0 。漏洞存在于 distribute() 函数中,该函数仅通过比较 abi.encodePacked 结果的哈希来验证参数,但由于 accounts 和 percents 是动态类型,因此可以在分配过程中进行调整。我们的解决方案是通过操纵 accounts 和 percents 数组,利用 distribute() 函数的参数验证不足,提取出比存入更多的 ETH。
漏洞分析
Split 合约的 distribute() 函数可以用来根据创建 SplitWallet 时指定的账户和百分比分配特定资产。分配后,用户可以根据 balances 中存储的值进行提现。然而, distribute() 函数存在参数验证不足的问题。该函数仅通过比较 abi.encodePacked 结果的哈希来验证参数,而 accounts 和 percents 是动态类型。因此,在分配过程中,我们可以稍微调整 accounts 和 percents。
在创建 SplitWallet{id: 0 } 时,第一个索引的账户被意外地留空了。
所以我们可以使用修改过的 accounts 和 percents 从 SplitWallet{id: 0 } 提取所有 ETH,但不分配给任何人,同时保持哈希不变(注意数组元素被填充到 32 字节)。
类似地,我们可以利用 abi.encodePacked 引起的哈希碰撞,提取比存入的 ETH 更多,以排空 Split。
解决方案
我们主要编写了 solve 函数来排空 SPLIT 和 _splitsById[ 0 ].wallet 的 ETH 余额。 整个解决方案的关键在于通过操纵 accounts 和 percents 数组,以及利用 distribute 函数的行为,在不违反哈希校验机制的情况下,提取出更多的 ETH。具体思路如下:
通过调整 accounts 和 percents 数组,可以控制 ETH 的分配。这里使用了一个只有一个地址(即我们的地址)的 accounts 数组和一个包含两个元素的 percents 数组。
利用 split.distribute 函数,将 ETH 从 SplitWallet 提取到我们的账户。这一步骤是通过在 distribute 函数中适当调整参数来实现的,以确保我们可以接收到 ETH。
接下来,创建一个 Split 实例,并设置我们的地址为接收者。
通过 split.deposit 函数存入一定量的 ETH,然后再次利用 split.distribute 函数提取更多的 ETH。
最后,调用 split.withdraw 函数,从 Split 合约中提取所有 ETH,完成挑战。
4. Dai++
这个挑战的目标是让 Stablecoin 的总供应量超过 10 ^ 12* 10 ^ 18 。漏洞在于 AccountManager 合约使用 ClonesWithImmutableArgs 创建新账户时,不变参数的长度限制被忽视了,导致当参数长度超过 65535 字节时,可能部署一个损坏的合约。我们的解决方案是创建一个包含过长参数的账户,使 increaseDebt() 函数变成一个幻影函数(phantom function),从而绕过健康检查,允许铸造大量稳定币而不增加债务。
漏洞分析
被 SystemConfiguration 合约授权的账户才可以铸造稳定币。只有 SystemConfiguration 的所有者才能更新系统合约(即授权账户),而 AccountManager 合约是唯一被授权的合约。
在 AccountManager 合约中,只有有效账户才能铸造稳定币。同时,账户上的债务也会增加。
在 increaseDebt() 函数中,如果在债务增加后账户不健康,则交易将失败。然而,玩家没有足够的 ETH 来铸造 10 ^ 12 个稳定币并保持账户健康。
值得注意的是,AccountManager 使用 ClonesWithImmutableArgs 来创建新账户。与账户交互时,不变的参数将从 calldata 中读取,以节省 gas 成本。但是 ClonesWithImmutableArgs 中有一条注释:“@dev 数据不能超过 65535 字节,因为 2 字节用于存储数据长度”。
由于不变的参数存储在创建的代理合约的代码区域中,因此在部署期间,代码大小将根据数据长度计算。然而,应该返回的代码大小也存储在 2 字节中。因此, 如果 runSize 超过 65535 字节,可能会部署一个损坏的合约。我们可以把 increaseDebt() 函数当作一个幻影函数来忽略这个调用。
现有参数长度是 20 + 20 + 32 = 72 字节,encoded recoveryAddresses 的长度将是 32 字节的倍数。
解决方案
我们编写了 Solve 合约,利用 AccountManager 合约的漏洞来铸造大量的稳定币。
首先,通过调用 AccountManager 的 openAccount 函数,创建一个包含异常长度参数的新 Account。这是通过传递一个长度为 2044 的空地址数组来实现的。由于参数长度超出预期的 65535 字节限制,这导致在内部创建的代理合约损坏。
为了确保参数长度正确,计算公式 72 + 2044 * 32 + 2 + 0x 43 - 11 = 65538 被使用。这里 72 是现有参数长度, 2044 * 32 是 recoveryAddresses 编码后的长度, 2 是存储数据长度的字节数,0x 43 是创建阶段字节码长度, 11 是运行时合约创建时的字节码长度。计算结果 65538 超出了最大长度 65535 ,因此在部署时会创建一个损坏的合约。
使用新创建的损坏的 Account 通过 mintStablecoins 函数铸造大量稳定币。由于账户合约损坏, increaseDebt 函数(应增加账户债务)实际上不会被正确执行,从而允许铸造稳定币而不增加任何债务。
5. DoDont
这个挑战的目标是窃取 DVM(代理投票机制)项目中的所有 WETH。漏洞位于 DVM.sol 的 init 函数中,该函数缺乏调用限制,允许任何人更改 BASE_TOKEN 和 QUOTE_TOKEN 地址。我们的解决方案利用了这个漏洞,通过在闪电贷过程中更改这些地址为我们控制的代币合约,绕过闪电贷机制的余额检查。
漏洞分析
在快速审查这个 DVM 项目后,我们注意到 DVM.sol 中的 init 函数缺乏任何调用限制条件。这是问题的根本原因。
我们可以随时调用 init() 函数来更改 BASE_TOKEN 和 QUOTE_TOKEN,这些是挑战中闪电贷的基础代币地址。在闪电贷中利用这样的漏洞很容易,因为我们只需要在闪电贷过程中将 BASE_TOKEN 和 QUOTE_TOKEN 更改为他们控制的代币合约地址。这允许他们在闪电贷期间控制余额,绕过闪电贷机制中的余额检查。
解决方案
我们创建了 Solve 合约,用于与挑战合约交互。创建一个 Exploit 合约用于执行攻击,该合约首先利用 flashLoan 函数获取 WETH 余额,然后通过 DVMFlashLoanCall 函数调用 init,更改 BASE_TOKEN 和 QUOTE_TOKEN 的地址为控制的代币合约。通过这种方式,我们可以绕过闪电贷机制的余额检查,并最终窃取 DVM 中的所有 WETH。
6.Grains of Sand
这个挑战的目标是将代币商店中的 GoldReserve(XGR)余额减少至少 11111 × 10 ^ 8 。漏洞在于 GoldReserve 代币在转移时会收取费用,但代币商店不支持带有转移费用的代币。我们的解决方案是通过反复存入和提取 GoldReserve 代币(这两个操作会有转移费用)来排空商店中的代币。
漏洞分析
这个挑战所在的私链是从以太坊主网的 18437825 号区块分叉而来的。
GoldReserve(XGR)代币在转移时会收取费用,但代币商店不支持有转移费用的代币。因此,我们可以通过反复存入和提取来从商店中排空代币。
现在我们需要先获得一些 GoldReserve 代币!通过 trade() 函数,我们可以用签名来换取 $XGR。
交易订单可以部分成交。通过 Dune,我们可以找到未过期的 GoldReserve 代币订单。幸运的是,有两个订单有大量未售出的代币。
解决方案
我们创建了 Solve 合约,用于与挑战合约交互。首先,通过 trade() 函数交易获得一些 GoldReserve 代币。然后利用代币商店中的存入和提取机制,反复进行操作来减少代币商店的代币余额。通过这种方式,可以成功地从代币商店中排空 GoldReserve 代币,满足挑战的条件。
7.Suspicious Charity
这个挑战的目标是操控 Python 脚本中的价格缓存,以影响代币的价格和流动性计算。挑战中的漏洞源自 Python 脚本根据名称缓存池中的代币地址,这些名称使用 string(uint 8) 构造时,超过 0x 80 的值在 Python 中会变得相同,导致错误的缓存。我们的解决方案是通过创建两个交易对:一个是高价格低流动性的交易对,用于更新缓存中的 tokenPrice;另一个是低价格高流动性的交易对,在“同名池”中更新 tokenAmount。通过这种方法,利用 Python 脚本中的错误计算,我们成功操控了代币价格和流动性,最终实现窃取 DVM 中的所有 WETH 的目标。
漏洞分析
这个问题源自于 Python 脚本根据名称缓存池中的代币地址,这些名称是使用 string(uint 8) 构造的。我们注意到, 当值超过 0x 80 时,它们在 Python 脚本中变得相同,这可能导致错误的缓存。在 Python 脚本的 get_pair_prices 函数中,这会导致错误的价格计算。
我们首先创建 78 个无用的交易对,然后创建两个可操控的交易对,发起攻击。
第一个交易对,其特点是价格高、流动性低,更新缓存中的 tokenPrice。随后,第二个价格低、流动性高的交易对在“同名池”中更新 tokenAmount。由于守护进程继续运行,它积累的捐赠值达到了相当高的数字。
解决方案
创建 Exploit 合约,用于完成挑战。该合约首先创建一些无用的代币交易对,然后创建一个高价格低流动性交易对和一个低价格高流动性交易对。通过这种方式,可以操控 Python 脚本中的价格缓存,使得在特定条件下,代币价格和流动性的计算出现错误。完成挑战后,将累积的价值转移到指定的地址。
8.Token Locker
这个挑战的目标是利用 UNCX_ProofOfReservesV2_UniV3 合约的漏洞来窃取该合约中的 NFT。漏洞在于 lock() 函数允许用户将流动性锁定在合约中,但该函数接收的 LockParams 结构体中的 nftPositionManager 参数可以被替换为恶意合约。这允许我们通过自定义的 NFT 位置管理器控制 NFT 的位置和流动性。我们的解决方案是创建一个 TokenLockerExploit 合约,它通过操作 UNCX_ProofOfReservesV2_UniV3 合约中的 lock 函数,以及使用 CustomNftPositionManager 合约来操纵 NFT 的位置和流动性。这样,我们就可以转移并控制 NFT 合约中的资产,最终成功排空合约中的资金。
漏洞分析
这个问题源自于合约 UNCX_ProofOfReservesV2_UniV3,它实际上是以太坊主网上 0x7f5C649856F900d15C83741f45AE46f5C6858234 合约的分叉。在快速审查代码后,我们需要更仔细地查看用户可以与之交互的外部函数,尤其是 lock() 函数。
在 UNCX_ProofOfReservesV2_UniV3 合约中,lock() 函数允许用户通过将流动性锁定在合约中来保护他们的流动性。这个函数提供了两个选择:用户可以将 NFT 转换为全范围并索取相关费用,这些费用随后将被返还给申请者,或者他们可以利用已经存在的位置。
这个函数接收结构体 LockParams 作为输入参数,特别是 nftPositionManager。
INonfungiblePositionManager nftPositionManager 的可用性意味着我们可以输入我们的合约,这将随后返回来自 UNCX_ProofOfReservesV2_UniV3 需要排空合约的外部调用。
在 lock() 函数执行期间,可能会调用 _convertPositionToFullRange() 函数。下面突出显示的是薄弱点。
我们只需传递如下参数:
mintParams.token 0 // nftPositionManager 返回真正的 Uniswap 位置管理器的地址
address(_nftPositionManager) // 自定义 nftPositionManager 的地址
mintParams.amount 1 Desired // 我们应该传递我们想要排空的 NFT ID。
因为 ERC 721 和 ERC 20 有相同的 transfer() 函数,所以在 _convertPositionToFullRange() 函数中的如下表达式导致将自己的 NFT 转移到恶意 nftPositionManager:
解决方案
我们创建了 TokenLockerExploit 合约,用于窃取 NFT。该合约通过操控 UNCX_ProofOfReservesV2_UniV3 合约中的 lock() 函数,以及通过 CustomNftPositionManager 合约来操纵 NFT 的位置和流动性,实现对合约资金的排空。
9. Skill Based Game
这个挑战的目标是通过连续赢得 BlackJack 游戏来耗尽 0xA65D59708838581520511d98fB8b5d1F76A96cad 以太坊主网上的所有资金。挑战的漏洞在于 BlackJack 游戏合约的发牌函数(Deck.deal())依赖于区块属性(如 block.number 和 block.timestamp)来模拟随机性,这可能使得结果被预测。我们的解决方案是创建一个 Attacker 合约来模拟发牌过程,并根据预测的结果决定是否进行实际的下注。
漏洞分析
为了完成这个挑战,我们需要提前知道将要抽取的牌,这样才能做出明智的决策来决定玩哪些游戏。现在,让我们深入了解合约如何管理发牌。玩家需要调用 deal() 函数,而在最后必须触发 checkGameResult():
发牌过程在 Deck.deal() 函数内处理。 这种生成随机性的方法依赖于区块属性和某些变量, 正如下面的代码片段所证明的。 这种实现引入了一个漏洞,允许结果被预测。
发牌过程涉及计算 blockhash、玩家地址、已发牌数和 block.timestamp 的哈希。这是一种众所周知的模仿随机性的方法,可以简单地通过等待所需的区块,根据新数据重新计算游戏结果,如果游戏结果符合我们的要求,那么我们就必须玩。
解决方案
我们创建了一个使用 Deck 库的 Attacker 合约来执行攻击。该合约首先模拟发牌过程,然后基于预测的结果决定是否进行实际的下注。
此时,我们只需反复执行这个合约中的 play() 函数,并用 5 ether 作为值,直到 BLACKJACK 合约中的资金耗尽。下面是实现此目的的脚本:
10. Enterprise Blockchain
这个挑战的目标是从 L1 链上的 l1 Bridge 中提取至少 10 个 FlagTokens。挑战的漏洞在于 L2 节点在处理特定的 ADMIN 预编译合约调用时可能发生崩溃,导致 L2 节点重启并从之前的状态中加载。我们的解决方案是利用这个漏洞,先在 L2 向 L1 发送远程消息将 FlagTokens 转移到 L1,然后触发 L2 节点崩溃和重启。通过这种方式,即使 L2 节点的状态恢复到转账发生前,资金已经成功转移到 L1,而 L2 上的资金并未减少,从而实现了挑战的目标。
漏洞分析
这里有两条链。
(1)挑战合约部署在 L1。最初,l1 Bridge 中有 100 个 FlagTokens(18 个小数)。
用户可以通过桥来在链之间转移资金。中继器将监听两个链上的 SendRemoteMessage 事件,并将消息转发到目标链。
为了发出 SendRemoteMessage 事件,我们可以调用 sendRemoteMessage() 函数,而在另一条链上要执行的交易可以自定义。
由于也提供了 L2 RPC,并且玩家拥有一些以太币,我们可以从 L2 向 L1 发送远程消息,并将代币从 l1 Bridge 转移到用户。
但是, the sendRemoteMessage() 函数并不打算公开使用 ,它预期只能通过 ethOut() / ERC 20 Out() 在链间转移资金。
( 2) L2 链上部署了一个 SimpleMultiSigGov 合约,位于地址 0x 31337 。它可以用来与预编译合约 ADMIN 互动。
ADMIN 的预编译合约有一个 fn_dump_state() 函数,其中的操作可能导致未定义行为。首先,x.len() 应该大于 0x 10 ,否则当 i == x.len() 时程序将因索引越界而发生 panic。states 是指向切片 [u 8 ] 的原始指针,切片在 x 86-64 上为 16 字节。states.offset 的计数单位是切片。由于 i 的最大值是 0x 10 ,所以应该分配的最小内存是 0x 110 (16 * (0x 10 + 1))而不是 0x 100 。因此, 如果 x.len() 大于 0x 10 ,程序将写入未分配的内存 states.offset(0x 10)。
调用 fn_dump_state() 时, 如果 x.len() > 0x 10 ,将会导致 L2 节点崩溃。anvil 服务将很快 重启 并从之前转储的状态中 加载状态。
状态转储间隔为 5 秒,但只要中继器捕获到 SendRemoteMessage 事件,它就会转发消息。如果 L2 节点在新的跨链转账交易被包含在区块中但最新状态尚未转储时崩溃,那么消息将被转发到 L1,而 L2 的状态只能恢复到转账发生前的状态。在这种情况下,用户可以将资金转移到 L1,而在 L2 中不花费任何资金。
只有位于 0x 31337 的 SimpleMultiSigGov 可以与 ADMIN 互动,但我们无法获得任何有效签名来执行交易。另外,我们可以利用状态覆盖集来临时覆盖 0x 31337 处的代码,并模拟调用。
ADMIN 的 admin_func_run() 函数是入口点。要调用 fn_dump_state() 函数,前两个字节应该是 0x 0204 。
解决方案
使用 pwn 和其他工具,我们可以执行一系列操作来触发 L2 节点崩溃,然后在 L2 节点重启并从之前的状态中加载时,执行跨链转移。通过这种方式,我们可以在不实际花费 L2 上任何资金的情况下,将资金转移到 L1。 这个过程需要精准的时序控制和对 L2 节点状态的操作。
11. Dragon Tyrant
这个挑战是一个与龙对抗的游戏,打败龙即可取得胜利,完成挑战。这个挑战的关键漏洞有两个:
( 1) 可预测的随机数: 游戏的随机数生成过程可以被预测,而这个随机数决定了攻击 / 防御决策,进而影响游戏胜负。游戏的随机数生成器依赖于可预测的种子,这些种子可以通过监控特定的区块链交易(resolveRandomness)被提前获取。我们的解决方案是首先通过交易池监听器监控并收集足够的种子信息,然后利用这些信息预测下一个种子。
( 2) 逻辑漏洞: 玩家只有装备上了传奇剑和盾牌,才会最大化自己的攻击值和防御力。游戏合约允许玩家传入他们自己的商店合约地址来购买装备,而验证这个自定义商店是否合法的机制是基于比较商店合约的 codehash 而不是其地址。这意味着,如果玩家能够创建一个具有与官方商店相同 codehash 但不同构造函数的合约,他们就可以绕过正常的购买流程和价格限制。我们的解决方案利用了这个漏洞,通过创建一个自定义的商店合约来购买传奇剑和盾牌,来绕过高昂的购买成本。
游戏背景
这个挑战背景是一个小游戏。游戏中 有一条龙,具有超强的力量 / 体质(高攻击力 / 防御力)和 60 点生命值. 而你作为, 主角,拥有随机生成的较弱属性和 1 点生命值, 需要击败这条龙。你和龙都是 ERC 721 代币,当龙在战斗中 失败并随后被销毁 时 (解决方案检查),挑战即为成功。
你必须发起战斗,战斗 最多有 256 个小回合。在每个回合中,你和龙都可以 选择攻击或防御。 伤害计算总结如下:
每个小回合后,双方的生命值都会减去相应的伤害。一方生命值达到 0 时,它就会被销毁,游戏结束。如果双方生命值都达到 0 , 发起攻击的一方——玩家——将被销毁。
龙和玩家的攻击 / 防御属性是 基于各自的属性和装备计算的。每方可以装备一件武器和一件盾牌。 商店中有一些装备出售,包括一把非常强大的剑。龙不会装备任何东西,而玩家初始拥有 1000 ETH。
游戏中有两个地方使用了随机数生成器。一个用于 确定玩家的属性,另一个用于 确定龙的攻击 / 防御决策。使用基于 ECC 的随机数生成器 ,种子 由链下提供。
攻击 / 防御决策
要击败龙,我们需要将它的生命值降到 0 ,同时保持我们唯一的生命值。查看攻击 / 防御矩阵,这意味着我们不能让攻击 / 攻击场景发生。在攻击 / 攻击回合中,双方的生命值都会降至 0 ,导致玩家失败。这是因为双方的攻击属性都远高于对方的生命值。由于防御 - 防御回合类似于 NOP,我们只能依靠攻击 / 防御回合和防御 / 攻击回合。
由于双方的攻击 / 防御决策都是提前提交的,我们需要事先知道龙的选择,以避免攻击 / 攻击回合。这需要我们预测随机数生成,进而需要我们预测随机种子。幸运的是,有一个 Python 库 可以预测 Python 的 random 模块的输出,一旦它观察到来自生成器的大约 20 k 比特的输出。
那么我们如何向这个库提供我们无法访问的 Python 随机模块的 20 k 比特输出呢?事实证明,我们可以 铸造任意数量的玩家 ,每个铸造交易都会 触发链下种子提供者提交一个随机种子。我们可以在待处理的交易池中监控这些交易,从而捕获种子。事实上,我们发现在捕获了 78 个铸造的种子后,我们可以预测随机种子:
由于 ECC 随机数生成器是确定性的,我们可以预测龙的攻击 / 防御决策。我们总是做与龙相反的事情。如果龙攻击,我们就防御。如果龙防御,我们就攻击。
攻击 / 防御属性
没有任何装备,我们的适度属性将导致我们在战斗中失败。龙拥有 type(uint 40).max 的攻击属性和 type(uint 40).max - 1 的防御属性。没有任何装备,当我们攻击而龙防御时,我们不会对龙造成任何伤害。当龙攻击而我们防御时,我们会立即失败。
自然而然地,我们将目光投向了 传奇剑。拥有这把剑后,我们的攻击属性将达到 type(uint 40).max,使我们在攻击并且龙防御时能对龙造成 1 点 HP 伤害。如果我们重复这个过程 60 次,龙就会死亡。这就有了希望。
我们怎样才能负担得起这把剑,它的价格是 100 万 ETH,而我们只有 1000 ETH?事实证明,当我们装备这把剑时,游戏允许我们自己传入商店合约,并且只要 商店合约之前已被工厂合约的所有者批准,游戏就会愉快地继续。仔细检查后发现,这种检查并不是通过验证商店合约地址来完成的,而是通过比较商店合约的 codehash 来完成的。这意味着,只要我们传入一个具有相同 codehash 的商店合约,我们就能继续。因为 extcodehash 不包括构造函数, 我们可以创建一个具有相同代码但不同构造函数的自己的物品商店,并使用它来为我们的玩家装备剑。
这个方法有效。使用以下构造函数的假商店,我们可以获得传奇剑以及新的传奇盾牌:
拥有这两件传奇装备,我们将实现 type(uint 40).max 的攻击属性和 type(uint 40).max 的防御属性。当龙攻击时,我们不会失去任何 HP,并且当我们攻击时,我们会对龙造成 1 点 HP 伤害。
解决方案
以下是解决方案的逐步过程:
将玩家代币铸造到我们自己的钱包中。
部署假商店,并使用它为玩家装备两件传奇装备。
部署攻击者合约,如挑战所要求的。该合约将接管玩家代币,发起战斗,并提供玩家的攻击 / 防御决策。
将玩家代币转移到攻击者合约。
启动待处理交易池监听器,监控 resolveRandomness 交易。它会捕获种子,并在收集到足够的信息后预测下一个种子。
铸造 78 个额外的玩家代币。
此时,池监听器应该已经收集到足够的信息以预测下一个种子。
将预测的种子输入随机数生成器,以确定龙的攻击 / 防御决策。
将龙的决策字符串按位反转,以推导出玩家的决策。当查询时,攻击者合约将提供玩家的决策。
攻击者合约发起攻击,导致龙的失败。
12. Hopping Into Place
这个挑战的目标是从一个跨链桥合约中提取所有资金。漏洞存在于 _additionalDebit() 函数中,这个函数在计算保证人(bonder)的责任时,如果 challengePeriod 被设置为 0 ,则不会增加任何债务。我们的解决方案是利用这个漏洞,通过设置 challengePeriod 为 0 ,使得 numTimeSlots 也为 0 ,从而阻止增加债务。接着,我们使用 bondTransferRoot() 函数提取任意数量的代币,因为 getDebitAndAdditionalDebit() 函数在这种情况下失去了其原本的功能,导致债务不会增加。通过这种方式,我们成功地排空了跨链桥中的资金。
漏洞分析
在这个挑战中,我们的身份是治理者,所以我们可以更改跨链桥的一些配置。
问题的根源在于 _additionalDebit() 函数,我们注意到债务是在 if 语句中添加的。这意味着如果 numTimeSlots 等于 0 ,则不执行该语句。保证人(bonder)的责任没有增加。显然,这种设计是不合理的;在任何情况下都不应跳过债务的增加。
我们可以利用这一点,通过将 challengePeriod 设置为 0 ,从而实现 numTimeSlots 为 0 的条件。
这样,getDebitAndAdditionalDebit 函数就失去了其额外的功能, 无论我们怎么操作,债务都不会增加。
这也影响了 requirePositiveBalance 修饰符,该修饰符要求在函数执行后,我们的信用必须大于增加的债务。然而,由于该函数失去了其额外的功能,我们的债务保持不变。这意味着我们可以使用此修饰符修改的函数来排空跨链桥。
最后,让我们看看 bondTransferRoot 中的逻辑。这个函数将 totalAmount 设置为调用者的债务,并将 totalAmount 添加到 transferRoots 以供提取。因此,我们可以使用这个函数提取任意数量的代币。
解决方案
我们编写了几个关键合约,通过将挑战期限设置为 0 来阻止增加附加债务,从而提取跨链桥中的资金。每个合约实现了特定的功能:
Exploit 合约:这是攻击的主要合约,负责执行整个攻击流程。它首先与挑战合约 Challenge 关联,然后通过一系列操作来操纵跨链桥 IBridge,最终达到提取资金的目的。
MockMessageWapper 合约:这个合约模拟了跨链消息传递的过程。在实际应用中,它并不执行任何有效操作,而是起到占位的作用,允许 Exploit 合约模拟跨链交互过程。
Solve 合约:这个合约继承自 CTFSolver,用于在 Capture The Flag (CTF) 挑战中与挑战合约 Challenge 进行交互。它主要负责调用 Exploit 合约的 exploit 方法来执行攻击,并在攻击成功后确认挑战是否已被解决。
IBridge 接口:这是一个定义了跨链桥合约方法的接口。它包含了 Exploit 合约中用到的跨链桥操作方法,如添加保证人、设置挑战期限、绑定转移根、提取资金等。
IChallenge 接口:这个接口定义了挑战合约 Challenge 中的方法,允许 Exploit 合约访问挑战中的跨链桥地址。
13. Oven
这个挑战的目标是恢复一个隐藏的 FLAG 值。挑战的核心是 fiat_shamir() 函数,这个函数使用了自定义的哈希函数 custom_hash() 来生成一个随机数,然后使用这个数参与计算。关键的漏洞位于 fiat_shamir() 函数中,尤其是在 r=(v - c * FLAG) mod (p-1) 这个表达式中,它涉及已知的 r, c, p 值和未知的 FLAG 值。解决方案是将这个问题转化为一个格问题,然后使用格基约减算法(LLL 算法)来找出 FLAG 值。
漏洞分析
代码功能:用户可以获取 FLAG 的随机签名,生成随机签名的逻辑位于 fiat_shamir() 函数中。使用了自定义的哈希函数 custom_hash 来生成哈希值,该函数调用了四种不同的哈希算法,因此目前无法破解其随机性。
此外,fiat_shamir 变换是密码学中非常重要的工具,其核心在于使用哈希算法生成随机数,为加密协议增加随机性。FS 变换的一个典型应用是将非交互性引入零知识证明系统中,然后构建诸如 snark 和 stark 等协议。
从源代码中,我们可以获取 t, r, p, g, y 等信息,但实际上 c 可以使用 custom_hash() 函数计算。因此,漏洞集中在 fiat_shamir() 函数中,它是对 FLAG 进行签名的功能部分,重点在于:r=(v - c * FLAG) mod (p-1)。对于这个等式,我们目前可以获取的信息是 r, c, p 都是已知值,且 FLAG 的位数已确定:assert FLAG.bit_length()<384 。 它可以与 1996 年 Dan Boneh 提出的 HNP 问题(带有可变模数)联系起来,并可以使用标准的格算法进行攻击。有关基于格攻击的更详细的密码分析,请参考相关论文。
问题在于代码中的 r=(v - c * FLAG) mod (p-1)。由于 r, c, p 都是已知值,那么:
首先,数学上转换上述等式:r-v+c*FLAG= 0 mod (p-1),其中只有 v 和 FLAG 是未知数。
其次,构建格:,其中 K 是 FLAG 的上界,所有空白处都是 0 。
根据 Babai 的 CVP 解决算法,必定存在一个解向量 j=[l1, l2, l3, FLAG, 1 ],使得 jM=jk 成立。
注意 jk 是格中的短向量,因此我们可以使用 LLL 算法在多项式时间内找到这个短向量。注意,短向量的每个元素可以用 64 位表示,因此确定了上界 K= 2 ^ 64 。
技巧: 这里是关于数据量的问题的说明。我们怎么知道恢复 FLAG 需要多少组数据?这需要使用高斯启发式来估计最短向量长度,并且所需的目标向量范数小于这个长度。但是,由于这是 CTF 比赛的背景,通常可以首先使用三到四或五组数据。如果不行,可以使用上述方法进行精确计算。这里,我们收集了五组数据以备用,但实际上只用了三组数据就解出了 FLAG。
解决方案
我们的代码需要在 sage-python 环境中运行。主要思路如下:
构建格:首先构建一个特定的格,其中包含已知的 p, c, r 值以及未知的 FLAG 值。这个格是由上述等式转化而来的。
使用 LLL 算法:应用 LLL 算法来寻找格中的短向量。LLL 算法是一种有效的算法,可以在多项式时间内找到格的一个基础向量,这个向量在数学上与原始问题的解相关。
恢复 FLAG:一旦找到短向量,就可以从中提取出 FLAG 的值。由于短向量中的元素可以用 64 位表示,这就为 FLAG 的大小设定了一个上限。
从竞赛到实践
Salute 团队在 Paradigm CTF 2023 比赛中累积了宝贵的经验,这些经验现在是 Salus 安全公司提供的增强型智能合约审计服务 的重要组成部分。如果您需要顶级的智能合约审计服务,请随时 联系我们 。 我们致力于为您的需求提供全面和高效的支持。