本文介绍了如何使用Hardhat工具在以太坊主网分叉中设置和修改存储变量的值,特别是在真实合约中找到各种类型变量的存储位置,并通过实用示例展示如何修改公共变量、映射和数组。通过这篇教程,开发者或白帽黑客可以安全地模拟链上操作,理解合约的存储布局和修改过程。
作者: Konstantin Nekrasov - MixBytes的安全研究员
Hardhat具有一个很酷的功能,可以使用hardhat_setStorageAt手动设置任何存储槽的值。这个功能对于白帽子来说非常有用,可以在以太坊主网上演示有效的漏洞,而不会造成实际损害。主网的分叉功能对于集成测试的开发者也很有用:模拟可能没有考虑到主网上真实合约的所有特性。
在本教程中,我们将设置一个Hardhat主网分叉,并通过几个示例演示如何在分叉上查找和修改真实合约中的存储变量。我们将涵盖不同类型的变量,包括简单整数、打包值、映射和数组。
首先你需要安装Hardhat。请查看这篇关于如何安装Hardhat并创建你的第一个项目的教程:
https://hardhat.org/tutorial/setting-up-the-environment
简而言之,你需要运行:
mkdir modify-storage-tutorial
cd modify-storage-tutorial
npm init -y
npm install dotenv hardhat @nomiclabs/hardhat-ethers @nomiclabs/hardhat-waffle ethereum-waffle ethers chai
在简单模式下,Hardhat在你的PC上本地模拟区块链。在分叉模式下,它将你的请求重定向到一个具有真实区块链快照的服务器。例如,alchemy.com 和 quicknode.com 提供这样的API。
你可以查看它们的教程,了解如何分叉以太坊主网:
https://docs.alchemy.com/alchemy/guides/how-to-fork-ethereum-mainnet https://learnblockchain.cn/article/11565
在本教程中,我们将使用Alchemy API。你必须访问 https://www.alchemyapi.io,注册并在其仪表板中创建一个新应用。你将获得配置Hardhat所需的API密钥。将其放入.env文件中,并不要忘记将文件名添加到.gitignore,因为该密钥是秘密的:
echo 'ALCHEMY_API_KEY=XXXXXXXXXX' >> .env
echo '.env' >> .gitignore
现在创建hardhat.config.js:
require("@nomiclabs/hardhat-waffle");
// 读取.env文件
require('dotenv').config()
// 访问 https://www.alchemyapi.io,注册,创建
// 在其仪表板中的新应用并将其密钥导出
// 到环境变量ALCHEMY_API_KEY
const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY;
if (!ALCHEMY_API_KEY) throw new Error("需要ALCHEMY_API_KEY");
/**
* @type import('hardhat/config').HardhatUserConfig
*/
module.exports = {
solidity: {
compilers: [
// 你可以为你的项目添加额外的版本
{
version: '0.8.9',
},
],
},
defaultNetwork: "hardhat",
networks: {
hardhat: {
forking: {
url: "https://eth-mainnet.alchemyapi.io/v2/" + ALCHEMY_API_KEY,
// 指定一个块进行分叉
// 如果你想从最后一个区块分叉,请删除此行
blockNumber: 14674245,
}
}
}
};
最后,你可以检查一切是否正常工作:
npx hardhat test
更改Tether USD合约的所有者地址
USDT智能合约有一个公共变量 address owner。让我们找到它的槽并将其更改为我们的签名地址。一旦完成,我们将能够运行一些特权方法,例如增加总供应量。
首先,我们添加一个接口来与USDT进行通信。该接口依赖于IERC20,因此我们需要安装Openzeppelin合约:
npm install @openzeppelin/contracts
现在添加一个 contracts/IUSDT.sol 文件:
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IUSDT is IERC20 {
function getOwner() external view returns (address);
function issue(uint256) external;
}
第一个猜测是所有者变量位于零槽。事实证明这是正确的!
const { expect } = require("chai");
const { ethers } = require("hardhat");
const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"
// 槽必须是去掉前导零的十六进制字符串!没有填充!
// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network
const ownerSlot = "0x0"
it("更改USDT所有权", async function () {
const usdt = await ethers.getContractAt("IUSDT", usdtAddress);
const [signer] = await ethers.getSigners();
const signerAddress = await signer.getAddress();
// 存储值必须是32字节长的、用前导零填充的十六进制字符串
const value = ethers.utils.hexlify(ethers.utils.zeroPad(signerAddress, 32))
await ethers.provider.send("hardhat_setStorageAt", [usdtAddress, ownerSlot, value])
expect(await usdt.getOwner()).to.be.eq(signerAddress)
})
你可以运行测试,看看它是否通过:
npx hardhat test test/ChangeUSDTOwner.js
现在我们是所有者,我们可以铸造额外的代币:
const { expect } = require("chai");
const { ethers } = require("hardhat");
const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7"
// 槽必须是去掉前导零的十六进制字符串!没有填充!
// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network
const ownerSlot = "0x0"
it("铸造USDT", async function () {
const usdt = await ethers.getContractAt("IUSDT", usdtAddress);
const [signer] = await ethers.getSigners();
const signerAddress = await signer.getAddress();
// 存储值必须是32字节长的、用前导零填充的十六进制字符串
const value = ethers.utils.hexlify(ethers.utils.zeroPad(signerAddress, 32))
await ethers.provider.send("hardhat_setStorageAt", [usdtAddress, ownerSlot, value])
expect(await usdt.getOwner()).to.be.eq(signerAddress)
const amount = 1000
const before = await usdt.totalSupply()
await usdt.issue(1000)
const after = await usdt.totalSupply()
expect(after - before).to.be.eq(amount)
})
运行测试以查看它是否通过:
npx hardhat test test/MintUSDT.js
更改USDC用户余额
现在我们来更改USDC智能合约中的用户余额。
用户余额存储在变量mapping(address => uint) balanceOf中。
我们可以直接通过hardhat_setStorageAt编辑余额,但首先我们需要找到正确的槽。这一点有点棘手。你可以查看映射在以太坊存储中是如何存储的,文档在 https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays
基本上,用户的余额存储在槽:
keccak256(padZeros(userAddress) . mappingSlot)
在javascript中的实现是:
function getSlot(userAddress, mappingSlot) {
return ethers.utils.solidityKeccak256(
["uint256", "uint256"],
[userAddress, mappingSlot]
)
}
那么我们如何知道mappingSlot呢?这是balanceOf变量的槽吗?我们将使用暴力搜索来找到它。你可以在https://blog.euler.finance/brute-force-storage-layout-discovery-in-erc20-contracts-with-hardhat-7ff9342143ed中读取如何做的示例。
我们将用简单的检查进行暴力搜索:
async function checkSlot(erc20, mappingSlot) {
const contractAddress = erc20.address
const userAddress = ethers.constants.AddressZero
// 槽必须是去掉前导零的十六进制字符串!没有填充!
// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network
const balanceSlot = getSlot(userAddress, mappingSlot)
// 存储值必须是32字节长的、用前导零填充的十六进制字符串
const value = 0xDEADBEEF
const storageValue = ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))
await ethers.provider.send(
"hardhat_setStorageAt",
[
contractAddress,
balanceSlot,
storageValue
]
)
return await erc20.balanceOf(userAddress) == value
}
这是暴力搜索的方法:
async function findBalanceSlot(erc20) {
const snapshot = await network.provider.send("evm_snapshot")
for (let slotNumber = 0; slotNumber < 100; slotNumber++) {
try {
if (await checkSlot(erc20, slotNumber)) {
await ethers.provider.send("evm_revert", [snapshot])
return slotNumber
}
} catch { }
await ethers.provider.send("evm_revert", [snapshot])
}
}
try..catch和evm_revert是必需的,因为随机存储修改可能会破坏合约并导致异常。现在我们可以编写一个最终测试,以检查我们是否能够在USDC合约中找到并修改用户余额:
it("更改USDC用户余额", async function() {
const usdc = await ethers.getContractAt("IERC20", usdcAddress)
const [signer] = await ethers.getSigners()
const signerAddress = await signer.getAddress()
// 自动找到映射槽
const mappingSlot = await findBalanceSlot(usdc)
console.log("找到USDC.balanceOf槽: ", mappingSlot)
// 计算 balanceOf[signerAddress] 槽
const signerBalanceSlot = getSlot(signerAddress, mappingSlot)
// 将其设置为值
const value = 123456789
await ethers.provider.send(
"hardhat_setStorageAt",
[
usdc.address,
signerBalanceSlot,
ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))
]
)
// 检查用户余额是否等于预期值
expect(await usdc.balanceOf(signerAddress)).to.be.eq(value)
})
运行测试以查看它是否通过:
npx hardhat test test/ChangeBalanceOf.js
修改Aave LendingPoolAddressesProviderRegistry
让我们分析一个简单的示例,如何查找、读取和修改保存在Aave的LendingPoolAddressesProviderRegistry中的私有动态地址数组,该数组存储在0x52D306e36E3B6B02c153d0266ff0f85d18BCD413中。
首先,我们需要知道以太坊状态中地址数组是如何存储的:
如果你对其他类型的数组如何存储感兴趣,请阅读https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays
LendingPoolAddressesProviderRegistry的代码在https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol
我们对这部分感兴趣:
contract LendingPoolAddressesProviderRegistry is ... {
mapping(address => uint256) private _addressesProviders;
address[] private _addressesProvidersList;
...
function getAddressesProvidersList()
external
view
returns (address[] memory)
{ ... }
function getAddressesProviderIdByAddress(
address addressesProvider
)
external
view
returns (uint256)
{ ... }
...
我们想找到_addressesProvidersList槽。首先,让我们通过调用getAddressesProvidersList方法检查其内容。为此,我们需要将LendingPoolAddressesProviderRegistry接口添加到我们的项目中:
interface ILendingPoolAddressesProviderRegistry {
function getAddressesProvidersList() external view returns (address[] memory);
function getAddressesProviderIdByAddress(address addressesProvider) external view returns (uint256);
}
现在我们可以在Hardhat的控制台中运行它。使用以下命令启动控制台:
npx hardhat console
然后运行以下javascript代码:
const target = await ethers.getContractAt("ILendingPoolAddressesProviderRegistry", "0x52D306e36E3B6B02c153d0266ff0f85d18BCD413")
await target.getAddressesProvidersList()
输出:
[
'0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',
'0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5'
]
所以,这个数组有两个元素。现在我们知道_addressesProvidersList的槽存储着0x02值。让我们读取前几个槽以找到值:
await ethers.provider.getStorageAt(target.address, "0x0")
await ethers.provider.getStorageAt(target.address, "0x1")
await ethers.provider.getStorageAt(target.address, "0x2")
输出:
0x000000000000000000000000b9062896ec3a615a4e4444df183f0531a77218ae
0x0000000000000000000000000000000000000000000000000000000000000000
0x0000000000000000000000000000000000000000000000000000000000000002
让我们分析存储布局:
让我们将槽2的值更改为0x03,这样数组_addressesProvidersList将有3个元素:
await ethers.provider.send(
"hardhat_setStorageAt", [
target.address,
// 槽必须是去掉前导零的十六进制字符串!没有填充!
// https://ethereum.stackexchange.com/questions/129645/not-able-to-set-storage-slot-on-hardhat-network
"0x2",
// 存储值必须是32字节长的、用前导零填充的十六进制字符串
ethers.utils.hexlify(ethers.utils.zeroPad(3, 32))
]
)
现在让我们调用getAddressesProvidersList看看是否有效:
await target.getAddressesProvidersList()
输出:
[
'0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',
'0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',
'0x0000000000000000000000000000000000000000'
]
成功了!现在让我们将数组的第三个元素设置为0xDEADBEEF:
const arraySlot = ethers.BigNumber.from(ethers.utils.solidityKeccak256(["uint256"], [2]))
const elementSlot = arraySlot.add(2).toHexString()
const value = "0xDEADBEEF"
const value32 = ethers.utils.hexlify(ethers.utils.zeroPad(value, 32))
await ethers.provider.send(
"hardhat_setStorageAt", [
target.address,
elementSlot,
value32,
])
现在,如果我们再次运行getAddressesProvidersList,我们将得到:
[
'0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',
'0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',
'0x00000000000000000000000000000000DeaDBeef'
]
但为什么?我们不是改变了第三个元素吗?原因在于getAddressesProvidersList如何工作。它仅输出存储在映射_addressesProviders中的元素。请参考代码 https://github.com/aave/protocol-v2/blob/master/contracts/protocol/configuration/LendingPoolAddressesProviderRegistry.sol#L33:
for (uint256 i = 0; i < maxLength; i++) {
if (_addressesProviders[addressesProvidersList[i]] > 0) {
activeProviders[i] = addressesProvidersList[i];
}
}
return activeProviders;
幸运的是,我们已经知道_addressesProviders映射的槽:它是槽1。我们可以直接将我们的0xDEADBEEF添加到 _addressesProviders中:
const deadBeefSlot = ethers.utils.solidityKeccak256(
["uint256", "uint256"],
[0xDEADBEEF, 1]
)
await ethers.provider.send(
"hardhat_setStorageAt",
[
target.address,
deadBeefSlot,
ethers.utils.hexlify(ethers.utils.zeroPad(1, 32))
]
)
让我们再检查一下我们的数组:
await target.getAddressesProvidersList()
输出:
[
'0xB53C1a33016B2DC2fF3653530bfF1848a515c8c5',
'0xAcc030EF66f9dFEAE9CbB0cd1B25654b82cFA8d5',
'0x00000000000000000000000000000000DeaDBeef'
]
太好了!值0x00000000000000000000000000000000DeaDBeef作为_addressesProvidersList数组的第三个元素存储。你可以通过如下方式运行完整脚本:
npx hardhat run scripts/ChangeAaveAddressProviderList.js
在本文中,我们分析了几种如何查找以太坊状态中不同类型变量槽的示例,以及如何读取和修改它们的值。我们查看了如何修改公共地址、公共映射(address => uint)和私有地址[],在USDT、USDC和Aave等合约中使用。
这些技巧肯定会帮助你准备和演示有效的漏洞。如果你不是白帽子而是开发者,那么这绝对会帮助你编写集成测试。
祝好运!
MixBytes是谁?
MixBytes 是一支专业的区块链审计和安全研究团队,专注于为EVM兼容和基于Substrate的项目提供全面的智能合约审计和技术咨询服务。加入我们,在X上随时关注最新的行业趋势和见解。
- 原文链接: mixbytes.io/blog/modify-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!