2、Solidity基础回顾:数据类型、函数修饰符、事件与继承、ABI编码
好,我们直接进入正题。Solidity 这门语言,说难不难,说简单也不简单。很多安全漏洞,其实都出在基础概念没吃透。我个人习惯是,不管多忙,每隔一段时间都会回来翻翻基础,尤其是数据类型和函数修饰符——这两个地方是漏洞高发区。
2.1 数据类型:值类型 vs 引用类型
Solidity 的数据类型,说白了就两大类:值类型和引用类型。这个区分非常重要,因为它直接决定了变量怎么存、怎么传、会不会被意外修改。
2.1.1 值类型
值类型包括:bool、int/uint、address、bytes1~bytes32、enum 等。它们的特点是:赋值或传参时,数据会被完整拷贝一份。
uint256 a = 100;
uint256 b = a; // 拷贝,b 是独立副本
b = 200;
// a 仍然是 100,不受影响
嗯,这里要注意:address 类型在 Solidity 0.8 之后分成了 address 和 address payable。后者才能接收以太币转账。我在审计中见过不少项目,因为用了 address 而不是 address payable,导致转账函数编译不过,或者被迫用 payable(address) 强转——这种写法其实有安全隐患,后面讲重入攻击时会细说。
2.1.2 引用类型
引用类型包括:string、bytes、array、struct、mapping。它们存储的是数据的指针(或者说引用),赋值时默认是传引用,不是拷贝。
uint[] storage arr1 = [1, 2, 3];
uint[] storage arr2 = arr1; // 引用,指向同一块存储
arr2[0] = 999;
// arr1[0] 也变成了 999
为什么会这样?因为 Solidity 的存储模型分 storage、memory 和 calldata 三种位置。引用类型必须显式指定数据位置。我见过最典型的漏洞是:函数参数用 storage 修饰,结果意外修改了链上状态。
storage 引用了一个状态变量,然后直接修改了它,导致用户余额被清零。记住:除非你明确要修改链上数据,否则引用类型参数尽量用 memory 或 calldata。
2.2 函数修饰符:谁可以调用?怎么调用?
函数修饰符控制着函数的访问权限和行为。我个人把它分成三类:可见性修饰符、状态修饰符、自定义修饰符。
2.2.1 可见性修饰符
| 修饰符 | 谁可以调用 | 常见用途 |
|---|---|---|
public |
内部和外部都可以 | 对外接口 |
external |
仅外部可以 | 节省 gas(参数用 calldata) |
internal |
仅本合约和子合约 | 内部逻辑 |
private |
仅本合约 | 敏感数据或逻辑 |
你想想看,external 和 public 的区别在哪?external 的参数默认存在 calldata 里,只读且 gas 更省。而 public 的参数会拷贝到 memory。所以,如果函数只对外部调用,用 external 更划算。
2.2.2 状态修饰符
view 和 pure 这两个修饰符,很多新手容易搞混。我简单解释一下:
view:可以读状态,但不能改状态。pure:既不能读也不能改状态,只能处理传入的参数。
function getBalance(address user) public view returns (uint256) {
return balances[user]; // 读状态,OK
}
function add(uint a, uint b) public pure returns (uint) {
return a + b; // 只处理参数,不碰状态
}
嗯,这里有个坑:view 函数如果被 external 调用,其实不会消耗 gas。但如果你在 view 函数里调用了另一个会修改状态的函数,编译器会报错。我在审计中见过有人用 view 函数偷偷调用链下服务——这其实是个设计问题,不是语法问题。
2.2.3 自定义修饰符
自定义修饰符是 Solidity 最强大的特性之一。它允许你在函数执行前后插入逻辑。
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_; // 继续执行原函数
}
function withdraw() public onlyOwner {
// 只有 owner 可以调用
}
我曾经审计过一个项目,它的修饰符里用了 _; 的位置不对,导致权限检查在函数执行之后才进行——相当于没检查。记住:_; 的位置决定了原函数代码插入的位置。通常放在修饰符的最后。
2.3 事件与继承
2.3.1 事件:链上的日志系统
事件是 Solidity 与外部世界通信的桥梁。当事件被触发时,它会写入交易收据的日志中,前端可以监听。
event Transfer(address indexed from, address indexed to, uint256 value);
function transfer(address to, uint256 amount) public {
// ... 转账逻辑
emit Transfer(msg.sender, to, amount);
}
注意 indexed 关键字。被 indexed 的参数可以被高效检索,最多三个。未 indexed 的参数会被编码到日志数据中,但无法直接过滤。
嗯,这里有个安全点:事件里的敏感数据(比如密码、私钥)不要直接 emit。因为链上数据是公开的,任何人都可以读取交易日志。我见过一个项目把用户邮箱地址直接 emit 了,结果被爬虫抓了个精光。
2.3.2 继承:代码复用的双刃剑
Solidity 支持多重继承,使用 is 关键字。继承顺序很重要,它决定了合约的布局和函数解析顺序(C3 线性化)。
contract A {
function foo() public virtual pure returns (string memory) {
return "A";
}
}
contract B is A {
function foo() public override pure returns (string memory) {
return "B";
}
}
为什么会这样?因为 Solidity 的继承是线性化的。如果 A 和 B 都定义了 foo,那么最终调用哪个取决于继承顺序。我建议:尽量保持继承链简单,不要超过三层。我在审计中见过一个项目,继承链有七层,结果一个函数调用绕了三个合约才找到实现——这种代码几乎没法审计。
virtual,子合约忘了 override,结果部署后那个函数永远返回默认值。记住:每个 virtual 函数都必须被 override,除非你明确想让它保持抽象。
2.4 ABI 编码:合约间通信的通用语言
ABI(Application Binary Interface)定义了如何编码和解码函数调用、事件日志等数据。说白了,就是合约之间、合约与外部之间如何“说话”。
2.4.1 编码函数
Solidity 提供了几个底层编码函数:
abi.encode():将参数编码为字节数组,填充到 32 字节对齐。abi.encodePacked():紧凑编码,不填充,节省空间但容易产生哈希碰撞。abi.encodeWithSelector():编码函数选择器 + 参数。abi.encodeWithSignature():根据函数签名编码。
bytes memory data = abi.encodeWithSignature("transfer(address,uint256)", to, amount);
(bool success, ) = address(token).call(data);
嗯,这里要特别小心 abi.encodePacked。因为它不填充,所以 abi.encodePacked("aa", "bb") 和 abi.encodePacked("aab", "b") 的结果是一样的——都是 0xaabb。这会导致哈希碰撞。我在审计中见过一个 NFT 项目,用 abi.encodePacked 生成 tokenURI,结果两个不同的参数组合生成了相同的哈希,导致元数据冲突。
abi.encode 而不是 abi.encodePacked。如果你需要节省 gas,并且能确保参数长度固定,才考虑 abi.encodePacked。
2.4.2 函数选择器
函数选择器是函数签名的 Keccak-256 哈希的前 4 个字节。它决定了调用哪个函数。
bytes4 selector = bytes4(keccak256("transfer(address,uint256)"));
// 0xa9059cbb
为什么会有函数选择器碰撞?理论上,两个不同的函数签名可能产生相同的前 4 字节哈希。虽然概率极低,但确实存在。我建议:在需要动态调用函数的场景中,除了检查选择器,还要检查参数类型,防止被恶意利用。
2.4.3 底层调用:call、delegatecall、staticcall
这三个底层调用是 Solidity 最危险也最强大的功能:
call:在目标合约的上下文中执行代码,msg.sender是调用者。delegatecall:在调用者自己的上下文中执行目标合约的代码,msg.sender不变。staticcall:类似call,但不允许修改状态。
(bool success, bytes memory data) = address(impl).delegatecall(
abi.encodeWithSignature("update(uint256)", newValue)
);
嗯,delegatecall 是代理模式的核心,但也是重入攻击和存储布局冲突的温床。我曾经审计过一个代理合约,它的存储变量顺序和逻辑合约不一致,结果 delegatecall 后,逻辑合约修改了错误的存储槽,导致整个系统崩溃。
delegatecall 时,一定要确保代理合约和逻辑合约的存储布局完全一致。我建议用 OpenZeppelin 的 Initializable 模式,或者用 EIP-1967 标准存储槽来避免冲突。
好了,这一章的基础内容就到这里。数据类型、函数修饰符、事件与继承、ABI 编码——这四个知识点是 Solidity 的基石。你想想看,如果连值类型和引用类型都分不清,后面怎么理解重入攻击?如果连 delegatecall 的上下文都搞不懂,怎么审计代理合约?
下一章,我们会进入真正的安全审计实战。我会带着你从零开始分析一个真实的漏洞案例。嗯,到时候你会看到,这些基础概念是如何在实战中发挥作用的。