本文深入探讨了在以太坊主网上使用 assembly 语言代替 Solidity 编写智能合约时可能出现的一些细微的内存损坏问题,以及由此产生的意外行为。文章通过具体的 Foundry 示例,详细解释了外部调用、未更新的空闲内存指针、不充分的内存分配、调用不存在的合约以及内联汇编中的溢出/下溢等问题,并提供了相应的解决方案。
在以太坊主网上工作的智能合约开发者使用汇编而不是常规的 Solidity 来节省 gas,从而减少用户的交易费用。然而,使用汇编而不是普通的 Solidity 可能会引入细微的内存损坏问题,并导致智能合约审计员和开发者应该注意的意外行为。
为了理解本深入探讨,你需要至少完成 Updraft 的汇编和形式化验证课程的第 1 节,或者已经具备以下方面的等效知识:
Solidity 的空闲内存指针
外部调用的返回值存储到下一个空闲内存地址(NFMA)的内存中,该地址通过读取空闲内存指针地址(FMPA - 0x40)的内存来检索。如果智能合约开发者没有考虑到这一点,当在汇编块之后发生外部调用时,外部调用的返回值可能会覆盖先前汇编块存储在内存中的数据。如果该数据被后续计算使用,这可能会导致隐蔽的漏洞。
为了确切地了解这种内存损坏是如何发生的,请检查这个简化的独立 Foundry 示例,它基于这个真实世界的发现:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
// 通过接口访问的单独合约
// 阻止了优化器“过于聪明”,有助于
// 更好地逼近真实世界的执行
interface IInfoRetriever {
function getVal() external returns(uint256);
}
// this contract gets used to return additional
// values required by the primary computation
// 此合约用于返回主要的计算所需的额外值
contract InfoRetriever is IInfoRetriever {
uint256 returnVal = 3;
function setVal(uint256 newVal) public {
returnVal = newVal;
}
function getVal() external view returns(uint256) {
return returnVal;
}
}
interface IHasher {
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result);
}
// actual implementation that computes a hash of
// two primary inputs and an optional number of
// secondary inputs
// 计算散列的实际实现
// 两个主要输入和可选数量的
// 二次输入
contract HasherImpl is IHasher {
// used to retrieve optional number of secondary inputs
// 用于检索可选数量的二次输入
IInfoRetriever infoRetriever;
constructor(IInfoRetriever _infoRetriever) {
infoRetriever = _infoRetriever;
}
function _getSecondaryInputs(uint256 dataPtr, uint256 inputsToFetch) private returns(uint256) {
for(uint256 i; i<inputsToFetch; i++) {
// make external call to retrieve the data
// 进行外部调用以检索数据
uint256 input = infoRetriever.getVal();
assembly {
// for each additional input, store it into memory
// at next free memory address
// 对于每个额外的输入,将其存储在内存中
// 在下一个空闲内存地址
mstore(dataPtr, input)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
}
}
// returns updated dataPtr
// 返回更新后的 dataPtr
return dataPtr;
}
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
assembly {
// read free memory pointer to find next free memory address
// 读取空闲内存指针以查找下一个空闲内存地址
dataPtr := mload(0x40)
// store `a` in memory at next free memory address
// 将 `a` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
// 将 `b` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
}
// get the additional input from the info retriever, passing the updated
// `dataPtr` so any new elements will be saved in subsequent free memory
// addresses
// 从信息检索器获取额外的输入,传递更新后的
// `dataPtr` 这样任何新的元素都会保存在随后的空闲内存中
// 地址
dataPtr = _getSecondaryInputs(dataPtr, additionalInputCount);
// compute hash of all inputs
// 计算所有输入的哈希
assembly {
let startPtr := mload(0x40)
result := keccak256(startPtr, sub(dataPtr, startPtr))
}
}
}
// test harness
// 测试工具
contract HasherTest is Test {
IInfoRetriever infoRetriever = new InfoRetriever();
IHasher hasher = new HasherImpl(infoRetriever);
uint256 a = 1;
uint256 b = 2;
function test_HashWithoutAdditionalInput() external {
// works great
// 效果很好
bytes32 result = hasher.hashInputs(a, b, 0);
assertEq(result, keccak256(abi.encode(1, 2)));
}
function test_HashWithAdditionalInput() external {
// fails due to calculating incorrect hash
// 由于计算不正确的哈希而失败
bytes32 result = hasher.hashInputs(a, b, 1);
assertEq(result, keccak256(abi.encode(1, 2, 3)));
}
}
这个简化的例子包含一个合约 HasherImpl
,其目的是:
接收一组主要输入(a, b
)
可选择检索一组辅助输入
计算整个主要和辅助输入集的哈希
HasherImpl
使用汇编来:
正确计算下一个空闲内存地址(NFMA)
正确地将主要和辅助输入存储到 NFMA 中
对整个输入集执行 gas 高效的哈希
当不存在辅助输入时,一切都正常工作,这可以通过 forge test --match-contract HasherTest --match-test test_HashWithoutAdditionalInput
来验证。
但是,当使用一个或多个辅助输入时,相关的测试 test_HashWithAdditionalInput
失败,因为计算了不正确的哈希。发生这种情况有两个原因 - 让我们详细检查第一个原因!
表面上检查汇编代码,一切看起来都很好;每个主要和辅助输入都保存在 NFMA 中,并且 dataPtr
始终更新为指向下一个 NFMA,这样任何变量都不会覆盖彼此。
但是,当检索辅助输入时,由于对 IInfoRetriever::getVal
的外部调用,会发生一个非常微妙的错误,因为:
手动汇编从未更新 FMPA(0x40),并将第一个输入 a
存储在起始下一个空闲内存地址(SNFMA - 0x80)
在为外部调用设置时,将从内存中加载 FMPA,并且外部调用中使用的临时值将存储在其指向的地址(0x80)
当外部调用完成时,将再次从内存中加载 FMPA,并将返回值存储在其指向的地址(0x80)
这会导致 0x80 处的内存损坏,在外部调用之前和之后覆盖输入变量 a
因此,哈希将永远不会在整个输入集上执行,因为至少一个输入被覆盖,从而导致计算出不正确的哈希。让我们使用 Foundry 的调试器逐步执行每个操作码,并查看内存损坏发生的准确时刻,来证明这正是发生的事情!
要启动调试器,请执行 forge test --match-contract HasherTest --debug test_HashWithAdditionalInput
。我们关心 HasherImpl::hashInputs
内的执行:
从在将 calldata 加载到堆栈上之后和调用 JUMPDEST
之后的第一个 PUSH1(0x40)
开始
直到发生内存损坏
相关的执行从 PC 0x56 (86) 开始,如下所示:
// push Free Memory Pointer Address (FMPA) onto stack
// 将空闲内存指针地址(FMPA)推送到堆栈上
PUSH1(0x40) [Stack : 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate FMPA
// 复制 FMPA
DUP1 [Stack : 0x40, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// load Starting Next Free Memory Address (SNFMA) by reading FMPA
// 通过读取 FMPA 加载起始下一个空闲内存地址(SNFMA)
MLOAD [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate value of `a` input
// 复制输入 `a` 的值
DUP5 [Stack : 0x01, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// duplicate SNFMA
// 复制 SNFMA
DUP2 [Stack : 0x80, 0x01, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80 ]
// store value of `a` into memory at SNFMA
// 将 `a` 的值存储到 SNFMA 的内存中
MSTORE [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// push 0x20 onto stack (2nd param of first `add` call)
// this is an offset to calculate Next Free Memory Address (NFMA)
// 将 0x20 推送到堆栈上(第一个 `add` 调用的第二个参数)
// 这是一个计算下一个空闲内存地址(NFMA)的偏移量
PUSH1(0x20) [Stack : 0x20, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// duplicate SNFMA
// 复制 SNFMA
DUP2 [Stack : 0x80, 0x20, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// calculate NFMA by SNFMA + offset (0x80 + 0x20)
// 通过 SNFMA + 偏移量(0x80 + 0x20)计算 NFMA
ADD [Stack : 0xa0, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// duplicate value of `b` input
// 复制输入 `b` 的值
DUP5 [Stack : 0x02, 0xa0, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// exchange 2nd and 1st elements
// 交换第 2 个和第 1 个元素
SWAP1 [Stack : 0xa0, 0x02, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01 ]
// store value of `b` into memory at NFMA
// 将 `b` 的值存储到 NFMA 的内存中
MSTORE [Stack : 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// not sure what the purpose of this is?
// 不确定这是什么目的?
PUSH1(0x00) [Stack : 0x00, 0x80, 0x40, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// exchange 3rd and 1st stack elements
// 交换第 3 个和第 1 个堆栈元素
SWAP2 [Stack : 0x40, 0x80, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// calculate NFMA by SNFMA + offset (0x80 + 0x40)
// 通过 SNFMA + 偏移量(0x80 + 0x40)计算 NFMA
ADD [Stack : 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// `a` and `b` primary inputs have been written to memory
// and the next free memory address 0xc0 has been calculated and
// on the stack. We skip opcodes concerned with calling internal
// function `getSecondaryInputs` and evaluating `for` loop condition
// until we reach PC 0xc2 (194) which is setting up for the external
// call
// `a` 和 `b` 主要输入已写入内存
// 并且下一个空闲内存地址 0xc0 已被计算出来并且
// 在堆栈上。我们跳过与调用内部相关的操作码
// 函数 `getSecondaryInputs` 和评估 `for` 循环条件
// 直到我们到达 PC 0xc2 (194),它正在为外部设置
// 调用
DUP2 [Stack : 0xe1cb0e52..00, 0x80, 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80, 0x80 = 0x01, 0xa0 = 0x02 ]
// before making external call the value of `a` at 0x80 will be
// over-written - memory corruption first occurs before external call!
// 在进行外部调用之前,0x80 处的 `a` 的值将被
// 覆盖 - 内存损坏首先发生在外部调用之前!
MSTORE [Stack : 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80,\
0x80 = 0xe1cb0e52..00, // `a` over-written\
0xa0 = 0x02]
// we then move forward into `InfoRetriever::getVal` until `RETURN`
// takes us back into `HasherImpl::_getSecondaryInputs` PC 0x0d5 (213),
// where we observe that 0x80 now contains the return value `3` from
// the external call
// 然后我们前进到 `InfoRetriever::getVal` 直到 `RETURN`
// 带我们回到 `HasherImpl::_getSecondaryInputs` PC 0x0d5 (213),
// 在那里我们观察到 0x80 现在包含来自外部调用的返回值 `3`
ISZERO [Stack : 0x84, 0xe1cb0e52..00, 0x5616..b72f, 0x00, 0x00, 0x00, 0x01, 0xc0, 0x71, 0xc0, 0x00, 0x01, 0x02, 0x01, 0x43, 0xe98b5f2d]
[Memory: 0x40 = 0x80,\
0x80 = 0x03, // `a` over-written again\
0xa0 = 0x02]
// manual assembly to store `3` gets executed at PC 0x10b (267)
// which stores the return value of the external call at 0xc0.
// however the return value `3` was also stored at 0x80 where `a` was
// previously stored so the correct hash can no longer be computed.
// also note that solidity has updated the FMPA 0x40 to point to
// 0xa0 which is where we are storing the value of variable `b` meaning
// that the second input variable could also be over-written
// 用于存储 `3` 的手动汇编在 PC 0x10b (267) 处执行
// 它将外部调用的返回值存储在 0xc0 处。
// 然而,返回值 `3` 也存储在先前存储 `a` 的 0x80 处
// 因此不能再计算正确的哈希。
// 还要注意,solidity 已经更新了 FMPA 0x40 以指向
// 0xa0,我们正在存储变量 `b` 的值,这意味着
// 第二个输入变量也可能被覆盖
MSTORE [Memory: 0x40 = 0xa0, // FMPA updated to where `b` stored!\
0x80 = 0x03, // `a` remains over-written\
0xa0 = 0x02, // `b` correct\
0xc0 = 0x03 // manual assembly stores return val]
上述操作码跟踪显示:
存储在 0x80 的内存中的输入变量 a
的值被覆盖两次;一次在外部调用之前,一次在外部调用之后
Solidity 已更新 FMPA 以指向 0xa0,这是手动汇编存储输入变量 b
的值的位置 - 这使得未来的操作也可能覆盖第二个输入变量
现在不可能计算正确的哈希,因为至少一个输入变量 a
由于手动汇编未考虑 Solidity 在外部函数调用期间如何使用内存而导致的内存损坏而被覆盖
要解决此问题,我们需要修改手动汇编中的第一个块,以更新空闲内存指针地址(FMPA)以指向下一个空闲内存地址(NFMA),如下所示:
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
assembly {
// read free memory pointer to find next free memory address
// 读取空闲内存指针以查找下一个空闲内存地址
dataPtr := mload(0x40)
// store `a` in memory at next free memory address
// 将 `a` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
// 将 `b` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
// @audit this prevents the external call in `_getSecondaryInputs`
// from over-writing value of `a` stored at 0x80
// @audit 这可以防止在 ``_getSecondaryInputs`` 中的外部调用
// 覆盖存储在 0x80 处的 ``a`` 的值
//
// update free memory pointer to point to next free memory address
// 更新空闲内存指针以指向下一个空闲内存地址
mstore(0x40, dataPtr)
}
进行此更改然后重新运行 Foundry 调试器显示在 PC 0x110 (272) 处:
外部调用的返回值存储在 0xc0 的内存中,主要和辅助输入变量已正确存储在内存中
FMPA 0x40 指向未使用的 0xe0,这样存储在内存中的任何新值都不会覆盖哈希计算所需的主要和辅助输入变量
MSTORE [Memory: 0x40 = 0xe0, // FMPA updated to unused value\
0x80 = 0x01, // `a` correct\
0xa0 = 0x02, // `b` correct\
0xc0 = 0x03] // returned secondary input correct
然而,这还不够,因为另一个微妙的错误正在等待破坏我们的哈希计算!
在混合手动汇编块和普通 Solidity 代码时,另一个微妙的问题是当手动汇编代码假设空闲内存指针地址(FMPA)没有改变,仅仅因为它没有改变它 - 这是一个危险的假设,因为手动汇编块之间的普通 Solidity 代码可能已经更新了 FMPA。
在先前的操作码执行跟踪中,我们观察到:
甚至在我们修复已识别的 bug 之前,Solidity 在从外部调用返回辅助输入值后更新了 FMPA
我们的修复需要在外部调用之前手动更新 FMPA
对 FMPA 的任何更新对我们的代码都是有问题的,因为计算哈希的手动汇编块:
从当前的 FMPA 读取下一个空闲内存地址(NFMA)
假设要散列的输入从该 NFMA 开始
// compute hash of all inputs
// 计算所有输入的哈希
assembly {
// @audit assumes FMPA unchanged from first assembly block
// where primary inputs are stored my first loading FMPA
// @audit 假设 FMPA 从第一个汇编块未更改
// 在将主要输入存储在我第一次加载 FMPA 的位置
let startPtr := mload(0x40)
result := keccak256(startPtr, sub(dataPtr, startPtr))
}
因此,一旦获取辅助输入的外部函数执行并且 FMPA 已被更新,即使没有发生内存损坏,哈希代码也永远不会正确地散列适当的输入。我们可以在操作码执行跟踪中观察到这一点,首先对于没有我们第一个修复的原始版本,从 PC 0x079 (121) 开始:
// exchange 2nd and 1st stack elements
// 交换第 2 个和第 1 个堆栈元素
SWAP1 [Stack : 0xa0, 0x40, ......]
[Memory: 0x40 = 0xa0,\
0x80 = 0x03, // value of 'a' corrupted by ext func\
0xa0 = 0x02,\
0xc0 = 0x03]
// call keccak256(offset, size)
// 调用 keccak256(offset, size)
// this will hash memory 0xa0 and 0xc0
// 这将散列内存 0xa0 和 0xc0
// returning keccak256(abi.encode(2,3))
// 返回 keccak256(abi.encode(2,3))
KECCAK256(0xa0, 0x40)
其次,在我们的“修复”版本中也发生了相同的错误,其中内存损坏问题已从 PC 0x7e (126) 开始解决:
// exchange 2nd and 1st stack elements
// 交换第 2 个和第 1 个堆栈元素
SWAP1 [Stack : 0xe0, 0x00, ......]
[Memory: 0x40 = 0xe0,\
0x80 = 0x01, // no memory corruption\
0xa0 = 0x02,\
0xc0 = 0x03]
// call keccak256(offset, size)
// 调用 keccak256(offset, size)
// this will hash empty memory since now FMPA points to unused memory
// 这将散列空内存,因为现在 FMPA 指向未使用的内存
KECCAK256(0xa0, 0x00)
要解决第二个问题:
第一个手动汇编块需要保存输入变量存储位置的起始内存地址
在外部函数调用之前,需要将 FMPA 更新为未使用的地址(我们的第一个修复)
最终的汇编块需要使用保存的起始内存地址来计算传递给 keccak256
的 offset 和 size 参数
完整的已修复源代码包含 @audit
标签以指示所做的所有更改:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// separate contracts accessed via interfaces
// prevents the optimizer from being "too smart", helping
// to better approximate real-world execution
// 通过接口访问的单独合约
// 阻止了优化器“过于聪明”,有助于
// 更好地逼近真实世界的执行
interface IInfoRetriever {
function getVal() external returns(uint256);
}
// this contract gets used to return additional
// values required by the primary computation
// 此合约用于返回主要计算所需的附加值
contract InfoRetriever is IInfoRetriever {
uint256 returnVal = 3;
function setVal(uint256 newVal) public {
returnVal = newVal;
}
function getVal() external view returns(uint256) {
return returnVal;
}
}
interface IHasher {
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result);
}
// actual implementation that computes a hash of
// two primary inputs and an optional number of
// secondary inputs
// 实现计算主要的哈希
// 两个输入和一个可选数量
// 可选输入
contract HasherImpl is IHasher {
// used to retrieve optional number of secondary inputs
// 用于检索可选数量的辅助输入
IInfoRetriever infoRetriever;
constructor(IInfoRetriever _infoRetriever) {
infoRetriever = _infoRetriever;
}
function _getSecondaryInputs(uint256 dataPtr, uint256 inputsToFetch) private returns(uint256) {
for(uint256 i; i<inputsToFetch; i++) {
// @audit once this external call completes it will:
// - read the free memory pointer to find next free memory address
// which will be 0x80 as we never updated it since we are manually
// allocating memory
// - write the result of the external call to this memory address
// - but this is where the input variable `a` was saved!
// - hence `a` gets subtly over-written by the return
// value of this external call which results in the hash calculation
// becoming corrupted!
// @audit 一旦此外部调用完成,它将:
// - 读取空闲内存指针以查找下一个空闲内存地址
// 由于我们从未手动更新它,因此将为 0x80
// 分配内存
// - 将外部调用的结果写入此内存地址
// - 但这是保存输入变量 ``a`` 的位置
// - 因此,外部调用的返回值略微覆盖了 ``a``
// 调用,这导致哈希计算
// 被破坏了!
//
// make external call to retrieve the data
// 进行外部调用以检索数据
uint256 input = infoRetriever.getVal();
assembly {
// for each additional input, store it into memory
// at next free memory address
// 对于每个额外的输入,将其存储在内存中
// 在下一个空闲内存地址
mstore(dataPtr, input)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
}
}
// returns updated dataPtr
// 返回更新后的 dataPtr
return dataPtr;
}
function hashInputs(uint256 a, uint256 b, uint256 additionalInputCount) external returns(bytes32 result) {
uint256 dataPtr;
// @audit used to hash correct memory addresses at the end
// @audit 用于在最后散列正确的内存地址
uint256 startDataPtr;
assembly {
// read free memory pointer to find next free memory address
// 读取空闲内存指针以查找下一个空闲内存地址
dataPtr := mload(0x40)
// @audit save starting address where we save our variables
// as 0x40 gets overwritten later
// @audit 保存我们保存变量的起始地址
// 作为 0x40 以后会被覆盖
startDataPtr := dataPtr
// store `a` in memory at next free memory address
// 将 `a` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, a)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
// store `b` in memory at next free memory address
// 将 `b` 存储在下一个空闲内存地址的内存中
mstore(dataPtr, b)
// update pointer to subsequent next free memory address
// 更新指针到随后的下一个空闲内存地址
dataPtr := add(dataPtr, 0x20)
// @audit this prevents the external call in `_getSecondaryInputs` from
// over-writing value of `a` stored at 0x80
// @audit 这可以防止来自 `_getSecondaryInputs` 的外部调用
// 覆盖存储在 0x80 处的 `a` 的值
//
// update free memory pointer to point to next free memory address```
uint mask = 256 ** len - 1;
// 右对齐数据
data = data >> (8 * (32 - len));
assembly {
// 缓冲区数据的内存地址
let bufptr := mload(buf)
// 地址 = 缓冲区地址 + sizeof(缓冲区长度) + off + len
let dest := add(add(bufptr, off), len)
mstore(dest, or(and(mload(dest), not(mask)), data))
// 如果我们扩展了缓冲区长度,则更新缓冲区长度
if gt(add(off, len), mload(bufptr)) {
mstore(bufptr, add(off, len))
}
}
return buf;
}
function resize(buffer memory buf, uint capacity) private pure {
bytes memory oldbuf = buf.buf;
init(buf, capacity);
append(buf, oldbuf);
}
function max(uint a, uint b) private pure returns(uint) {
if (a > b) {
return a;
}
return b;
}
}
// 测试工具
contract BufferTest is Test {
using Buffer for Buffer.buffer;
function test_MemoryCorruption() external {
Buffer.buffer memory buffer;
buffer.init(1);
// `foo` 紧随 buffer.buf 位于内存中
bytes memory foo = new bytes(0x01);
// 健全性检查通过
assert(1 == foo.length);
// 将 "A" 0x41 (65) 附加到缓冲区。 这将被写入 `foo.length` 的高位字节!
buffer.append("A");
// foo.length == 0x4100000000000000000000000000000000000000000000000000000000000001
// == 29400335157912315244266070412362164103369332044010299463143527189509193072641
// 这通过了,表明内存损坏
assertEq(29400335157912315244266070412362164103369332044010299463143527189509193072641,
foo.length);
}
}
使用 forge test --match-contract BufferTest --debug test_MemoryCorruption
在 Foundry 的调试器中运行测试,让我们检查一下这种内存损坏是如何发生的:
// PC 0x9b6 (2486) 在 `Buffer::init` 中执行此行:
// mstore(0x40, add(ptr, capacity))
// 将空闲内存指针地址 (FMPA) 更新为 0x120
MSTORE(0x40, 0x120)
Memory: [0x40 = 0x120, // FMPA 已更新\
0x80 = 0x100,\
0xa0 = 0x20,\
0xc0 = 0x60]
// PC 0x697 (1687) 在 `BufferTest::test_Mem...` 中执行此行
// bytes memory foo = new bytes(0x01);
// 将 `foo.length` 写入 0x120
MSTORE(0x120, 0x01)
Memory: [0x40 = 0x120,\
0x80 = 0x100,\
0xa0 = 0x20,\
0xc0 = 0x60,\
0x120 = 0x01] // foo.length
// PC 0xbad (2989) 在 `Buffer::write` 中执行此行
// mstore(dest, or(and(mload(dest), not(mask)), data))
// 覆盖 `foo.length` 的高位字节
MSTORE(0x101, 0x41)
Memory: [0x40 = 0x120,\
0x80 = 0x100,\
0xa0 = 0x20,\
0xc0 = 0x60,\
0x120 = 0x4100..01] // foo.length 内存已损坏
应用建议的修复会将 foo.length
写入 0x140,从而防止内存损坏。 类似的调查结果:[ 1, 2]
当使用底层 call
时,调用未部署代码的地址总是成功的。 考虑基于这个真实世界的 mainnet 发现的以下代码:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// 外部智能合约钱包,用于验证给定哈希的钱包签名
interface IWallet {
function isValidSignature(bytes32 _hash, bytes calldata signature) external returns (bool);
}
// 通过接口访问的单独合约
// 阻止优化器“过于聪明”,有助于
// 更好地近似实际执行
interface IWalletVerifier {
function isValidWalletSignature(bytes32 _hash, address walletAddress, bytes memory signature)
external view returns(bool);
}
// 实现用于进行外部调用的易受攻击函数
contract WalletVerifier is IWalletVerifier {
function isValidWalletSignature(bytes32 _hash, address walletAddress, bytes calldata signature)
external view returns (bool isValid) {
bytes memory callInput = abi.encodeWithSelector(
IWallet(walletAddress).isValidSignature.selector,
_hash,
signature
);
assembly {
let cdStart := add(callInput, 32)
let success := staticcall(
gas(), // 转发所有 gas
walletAddress, // Wallet 合约的地址
cdStart, // 指向输入开始位置的指针
mload(callInput), // 输入的长度
cdStart, // 将输出覆盖到输入之上
32 // 输出大小为 32 字节
)
switch success
case 0 {
// 使用 `Error("WALLET_ERROR")` 进行回退
/* snip */
revert(0, 100)
}
case 1 {
// 如果调用未回退并返回 true,则签名有效
isValid := mload(cdStart)
}
}
return isValid;
}
}
// 测试工具
contract CallTest is Test {
IWalletVerifier verifier = new WalletVerifier();
function test_EOAAddressCallVerifiesInvalidSig() external view {
bytes32 emptyHash;
bytes memory emptySignature;
bool returnVal = verifier.isValidWalletSignature(emptyHash, address(0x1234), emptySignature);
assert(returnVal);
}
}
函数 WalletVerifier::isValidWalletSignature
通过调用外部钱包智能合约来验证签名。 但是,如果调用此函数时传递了外部所有帐户 (EOA - 普通用户钱包) 的地址,那么它将始终返回 true,因为:
对于 EOA 地址,对 walletAddress
的 staticcall
将始终返回 1
staticcall
指定外部调用的输出应覆盖输入内存
在这种情况下,没有外部输出,因此输入将不会被覆盖
因此,mload
将返回有效数据,这些数据将被转换为 true
,从而导致返回变量 isValid
被设置为 true
这将允许为 EOA 帐户验证无效签名。 要在使用底层调用时解决此漏洞:
if iszero(extcodesize(walletAddress)) {
// 使用 `Error("WALLET_ERROR")` 进行回退
revert(0, 100)
}
if iszero(eq(returndatasize(), 32)) {
// 使用 `Error("WALLET_ERROR")` 进行回退
revert(0, 100)
}
使用内联汇编进行诸如 add
和 mult
之类的操作码不提供任何溢出/下溢保护,而正常的 Solidity 提供了这些保护。 考虑基于这个独特的 审计竞赛发现 的以下简化的独立示例:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// 通过接口访问的单独合约
// 阻止优化器“过于聪明”,有助于
// 更好地近似实际执行
interface IDexPair {
function getSwapQuote(uint256 amountToSwap) external view returns(uint256);
}
// 表示两个资产之间的去中心化交易所对
contract DexPair is IDexPair {
// 给定输入代币的数量
// 始终返回 +1 个输出代币
function getSwapQuote(uint256 amountToSwap) external view returns(uint256 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
}
}
}
// 测试工具
contract OverflowTest is Test {
IDexPair dexPair = new DexPair();
function test_SwapQuoteOverflow() external {
// 正确:交换 1 个代币返回 2 个代币
assertEq(2, dexPair.getSwapQuote(1));
// 不正确:由于溢出,交换最大值返回 0
assertEq(0, dexPair.getSwapQuote(type(uint256).max));
}
}
为了防止在使用 add
内联汇编时发生溢出,一种选择是手动检查结果是否不小于输入:
function getSwapQuote(uint256 amountToSwap) external view returns(uint256 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
// 如果 outputTokens < amountToSwap,则检测溢出
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
使用内联汇编时,还需要为乘法和减法实现适当的溢出/下溢检查。
当先前的示例 solidity 代码使用 uint128
或更小的类型而不是使用 uint256
时,可能会存在另一种类似但更微妙的溢出漏洞 - 建议的溢出检查缓解措施无法捕获此溢出! 考虑基于实际审计发现 的以下简化的独立示例:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.25;
import {Test} from "forge-std/Test.sol";
// 通过接口访问的单独合约
// 阻止优化器“过于聪明”,有助于
// 更好地近似实际执行
interface IDexPair {
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens);
}
// 表示两个资产之间的去中心化交易所对
contract DexPair is IDexPair {
// 给定输入代币的数量,始终返回 +1 个输出代币
// 将最大输入限制为 uint128 并使用显式代码来
// 检测溢出
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
// 如果 outputTokens < amountToSwap,则检测溢出
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
}
// 测试工具
contract OverflowTest is Test {
IDexPair dexPair = new DexPair();
function test_SwapQuoteUint128Overflow() external {
// 正确:交换 1 个代币返回 2 个代币
assertEq(2, dexPair.getSwapQuoteUint128(1));
// 不正确:由于溢出,交换最大 uint128 返回 0
// 汇编溢出检查未能捕获此溢出!
assertEq(0, dexPair.getSwapQuoteUint128(type(uint128).max));
}
}
即使 DexPair::getSwapQuoteUint128
将其输入和输出参数都限制为 uint128
,内联汇编也始终在 256 位值上运行。 因此:
add
操作码始终返回 256 位值
即使 type(uint128).max
作为 amountToSwap
的输入传递,在内联汇编块中,outputTokens
的值也将以 256 位表示,因此大于输入 amountToSwap
因此,使用 lt
操作码的溢出检测将无法检测到溢出,但溢出仍然在内联汇编块之后发生,导致该函数返回不正确的值
使用小于 uint256
的数字时,一种选择是使用 addmod
来防止使用完整的 256 位:
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
// 使用 addmod(a,b,N),其中 N = type(uint128).max
// 防止使用完整的 256 位
outputTokens := addmod(amountToSwap, 1, 340282366920938463463374607431768211455)
// 如果 outputTokens < amountToSwap,则检测溢出
if lt(outputTokens, amountToSwap) { revert(0, 0) }
}
}
检测这些更微妙的溢出的另一种选择是在内联汇编块之后使用正常 Solidity 包括检查:
function getSwapQuoteUint128(uint128 amountToSwap) external view returns(uint128 outputTokens) {
assembly {
outputTokens := add(amountToSwap, 1)
}
// 在内联汇编之外检测溢出以
// 防止由于以下原因而未检测到 uint128 溢出
// 内联汇编使用 256 位值
require(outputTokens >= amountToSwap, "Overflow detected!");
}
20
>- 原文链接: [dacian.me/solidity-inlin...](https://dacian.me/solidity-inline-assembly-vulnerabilities)
>- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!