本文作者分享了他在进行漏洞赏金活动中所用到的一个有趣技巧,探讨了Web3安全领域的经验,并详细介绍了如何通过获取状态变量的值和修改其存储来展示合约漏洞。文章详细阐述了获取环境、构建PoC的步骤及其实现方法,最后提供了多种提升PoC效果的技巧。
最后,我完成了一些漏洞赏金项目! 我写这篇文章是为了分享一个我在进行这个漏洞赏金项目时使用的有趣技巧,但我发现自己写下了整个经验,并认为作为整体分享很有趣。
你可以点击这里跳到相关部分。 这是我自从开始做web3安全以来的目标之一。但这花了我一些时间,原因是安全竞赛在初期阶段相对较容易。 初期阶段竞赛较容易的原因如下:
竞赛 | 漏洞赏金 | |
---|---|---|
合约文件 | <ul><li>可运行的仓库带测试</li><li>可以通过本地部署运行和演示</li><li>由于console.log,调试和修改很方便</li></ul> | <ul><li>部署的文件可能与仓库文件不同</li><li>必须分叉链并为每个合约获得abi</li><li>调试和记录更困难</li></ul> |
投资回报率(\$) | <ul><li>如果你发现了一个漏洞,你会得到报酬</li><li>但只有少数人(前三名到五名)可以赚到可观的金额</li></ul> | <ul><li>你需要是第一个报告的人才能获得报酬</li><li>如果你发现了一个漏洞,你知道你将得到多少钱</li></ul> |
学习经验 | <ul><li>由于竞赛最终报告提供的反馈循环,使学习效果显著</li><li>时间有限,意味着你无法总是深入到你需要/想要的程度</li></ul> | <ul><li>没有反馈循环</li><li>你可以根据需要深入挖掘以发现有趣的漏洞</li></ul> |
一般公认的是,安全竞赛在金钱方面的投资回报率低于漏洞赏金,但在学习方面的投资回报率更高(至少在进入该领域时)。
但显然这取决于每个人,我们也有先从漏洞赏金开始的安全研究员的例子(如果我没记错的话,@deadrosexyz就是其中之一)。
无论如何,话虽如此,我并不觉得自己已准备好进行直接的实时猎杀,因此我决定先参加竞赛,因为这将让我获得更多关于我工作的反馈。
我这样做了一年多,没有尝试跳入现实。但最近的一次竞赛给了我最终尝试的机会。
在这个过程中,我学到了很多很酷的新东西,因此我希望与社区分享。
机会出现在我参与LoopFi时,这是一个DeFi协议,允许用户通过“循环”将其质押的ETH转化为更多的质押ETH,即存入质押ETH并借入ETH。
该协议也是Gearbox的一个高度自定义的分叉。
在阅读之前的安全报告时,我偶然发现了一个非常有趣的发现,因为它与我在审查代码时记录的一些注释相关。
经过一些挖掘,我认为提出的整改确实是正确的。
我将其保存在脑海的一个角落中,比赛结束后决定检查它在所分叉的项目Gearbox中的实现。
经过更多的调查,我几乎可以确定Gearbox中存在一个漏洞,因为其实现正是被描述为LoopFi中的一个漏洞。
现在是时候上手了。
这篇文章并不是关于漏洞本身,而是关于实时漏洞猎杀的过程。
首先要检查协议是否有漏洞赏金计划。虽然这可能不是必需的,但仍然更倾向于猎杀那些有内部流程来管理这些情况的协议。
该信息通常可以在协议文档中找到。
之后,你需要设置你的环境以便高效工作。我最终有两个不同的仓库:
第一个仓库让我能够轻松记录和浏览代码库,运行测试等等。 第二个仓库是用来编写PoC或碰撞(落麦克风)实时合约的。
我个人更喜欢将这两者分开,尤其是在分享PoC给相关人员时。
我也更喜欢使用IDE,但有些牛人(尊敬)仅通过Etherscan或使用dethcode进行猎杀。
快进到你找到一个漏洞的时刻⏩
一旦漏洞确认,如果还没有做(因为你在猎杀过程中可能已经使用了PoC模板),是时候编写PoC了。
这部分可能会让习惯于竞赛的人有些紧张,因为你将没有直接访问整个协议文件和测试的权限。
PoC大致可以分为三部分:
你可以在该示例中看到步骤(1)和(2),来自Immunefi示例
pragma solidity ^0.8.13;
import "../../pocs/HundredFinanceHack.sol";
import "../../src/PoC.sol";
contract HundredFinanceHackTest is PoC {
uint256 mainnetFork;
HundredFinanceHack public hundredFinanceHack;
IERC20[] tokens;
IERC20 constant husd = IERC20(0x243E33aa7f6787154a8E59d3C27a66db3F8818ee);
IERC20 constant hxdai = IERC20(0x090a00A2De0EA83DEf700B5e216f87a5D4F394FE);
IERC20 constant wxdai = IERC20(0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d);
function setUp() public {
mainnetFork = vm.createFork("gnosis", 21120000);
vm.selectFork(mainnetFork);
//* .... *//
在尝试演示该问题时,我在展示某个特定值是如何被错误计算的方面遇到了一些困扰。
为了说明这一点,假设你想证明一个值x
是由某个值a
膨胀的。
但x
不可直接读取,因为它是方程y = x*b + z*c + d
的一部分,其中b
、c
和d
是每个区块都会变化的状态变量,而y
是一个重要的公共状态变量。
那么,我如何能轻松地证明x = x' + a
而不仅仅是x
呢?
当然,你需要在报告中以数学方式演示这一点,但由于在漏洞赏金平台上报告漏洞几乎不可能有PoC,因此将PoC制作得尽可能有影响力是很重要的。
我的想法是改变b
、c
和d
的值,使方程变得非常简单。
通过设定b = 1
,c, d = 0
,方程变为y = x
,这使得证明x
的计算有问题变得异常简单。
如何在分叉环境中实现这样的状态?
解决方案:神圣的圣杯Foundry
Forge标准库有两个有趣的函数,允许从某个地址以任意槽读取和写入状态变量:vm.store和vm.load。
vm.store
允许轻松更新分叉合约中的不可变和私人状态变量。
我们以该UniswapV2对为例(下面的截图来自Sim Explorer,以前被称为evm.storage)
作为第一个示例,假设我们想要覆盖totalSupplyValue
:我们发现它位于槽0x00..000
,或者简单地说0
。
那么,覆盖其值很简单:
function OverwriteTotalSupply(address pairV2, uint256 newValue) public {
newValue = 5000;
vm.store(address(pairV2), bytes32(uint256(0)), bytes32(newValue));
}
totalSupply
中存储的值现在是5000,而不再是以前的值,你可以用这个值运行你的PoC!
让我们再看一个示例,仅覆盖reserve1
值。
正如我们所看到的,reserve1
是一个uint112
,并与其他两个值reserve0
和blockTimestampLast
共享同一槽。
问题是,vm.store
只能写入bytes32
值,换句话说,就是整个槽。
因此,我们不得不使用一些基本的逻辑操作来提取和插入我们感兴趣的数据。
这是一个注释片段,演示了如何实现这一点:
function OverwriteReserve1(address pairV2, uint256 newReserve1) public {
uint112 newReserve1 = 5000; // `reserve1`的新值
// 第一步:读取槽8中当前的值
// |----blockTimestampLast------|----------reserve1----------|----------reserve0----------|
// | <blockTimestampLast-value> | <reserve1-value> | <reserve0-value> |
bytes32 currentSlotValue = vm.load(address(pairV2), bytes32(uint256(8)));
// 第二步:创建一个掩码以将`reserve1`的112位清零(从第112位到第223位)
// 掩码:
// |----blockTimestampLast------|----------reserve1----------|----------reserve0----------|
// | 11111111111111111111111... | 000000000000000000000... | 1111111111111111111111... |
bytes32 mask = ~(bytes32(uint256(type(uint112).max)) << 112);
// 第三步:仅清除旧的`reserve1`值,通过(槽 AND 掩码)
// |----blockTimestampLast------|----------reserve1----------|----------reserve0----------|
// | <blockTimestampLast-value> | 000000000000000000000... | <reserve0-value> |
// 第四步:使用OR插入新的`reserve1`值
// |----blockTimestampLast------|----------reserve1----------|----------reserve0----------|
// | <blockTimestampLast-value> | <new-reserve1-value> | <reserve0-value> |
bytes32 newSlotValue = (currentSlotValue & mask)
| (bytes32(uint256(newReserve1)) << 112);
// 第五步:将新值写入槽8
vm.store(address(reserves), bytes32(uint256(8)), newSlotValue);
}
通过这个,你可以更新所有你需要的值类型和结构状态变量。
对于映射和数组,这会更棘手,所以我会把你指引到noXX的这篇精彩文章:EVM深度剖析:通往阴暗超编码者的道路
你可以使用很多其他技巧来改变实时合约的行为,以便于你的PoC,例如使用mockCall强迫函数返回特定值,或者甚至使用etch,
这将允许你用轻微修改过的版本(添加console.log)替换已部署合约的字节码!
希望你在阅读这篇文章时度过了一段美好的时光。
- 原文链接: github.com/InfectedIsm/B...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!