2、Solidity基础回顾:数据类型、函数修饰符、事件与继承、ABI编码

好,我们直接进入正题。Solidity 这门语言,说难不难,说简单也不简单。很多安全漏洞,其实都出在基础概念没吃透。我个人习惯是,不管多忙,每隔一段时间都会回来翻翻基础,尤其是数据类型和函数修饰符——这两个地方是漏洞高发区。

2.1 数据类型:值类型 vs 引用类型

Solidity 的数据类型,说白了就两大类:值类型和引用类型。这个区分非常重要,因为它直接决定了变量怎么存、怎么传、会不会被意外修改。

2.1.1 值类型

值类型包括:boolint/uintaddressbytes1~bytes32enum 等。它们的特点是:赋值或传参时,数据会被完整拷贝一份。

uint256 a = 100;
uint256 b = a;  // 拷贝,b 是独立副本
b = 200;
// a 仍然是 100,不受影响

嗯,这里要注意:address 类型在 Solidity 0.8 之后分成了 addressaddress payable。后者才能接收以太币转账。我在审计中见过不少项目,因为用了 address 而不是 address payable,导致转账函数编译不过,或者被迫用 payable(address) 强转——这种写法其实有安全隐患,后面讲重入攻击时会细说。

2.1.2 引用类型

引用类型包括:stringbytesarraystructmapping。它们存储的是数据的指针(或者说引用),赋值时默认是传引用,不是拷贝。

uint[] storage arr1 = [1, 2, 3];
uint[] storage arr2 = arr1;  // 引用,指向同一块存储
arr2[0] = 999;
// arr1[0] 也变成了 999

为什么会这样?因为 Solidity 的存储模型分 storagememorycalldata 三种位置。引用类型必须显式指定数据位置。我见过最典型的漏洞是:函数参数用 storage 修饰,结果意外修改了链上状态。

⚠️ 避坑指南: 我曾经审计过一个 DeFi 项目,开发者在函数内部用 storage 引用了一个状态变量,然后直接修改了它,导致用户余额被清零。记住:除非你明确要修改链上数据,否则引用类型参数尽量用 memorycalldata

2.2 函数修饰符:谁可以调用?怎么调用?

函数修饰符控制着函数的访问权限和行为。我个人把它分成三类:可见性修饰符、状态修饰符、自定义修饰符。

2.2.1 可见性修饰符

修饰符 谁可以调用 常见用途
public 内部和外部都可以 对外接口
external 仅外部可以 节省 gas(参数用 calldata)
internal 仅本合约和子合约 内部逻辑
private 仅本合约 敏感数据或逻辑

你想想看,externalpublic 的区别在哪?external 的参数默认存在 calldata 里,只读且 gas 更省。而 public 的参数会拷贝到 memory。所以,如果函数只对外部调用,用 external 更划算。

2.2.2 状态修饰符

viewpure 这两个修饰符,很多新手容易搞混。我简单解释一下:

  • 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 可以调用
}

我曾经审计过一个项目,它的修饰符里用了 _; 的位置不对,导致权限检查在函数执行之后才进行——相当于没检查。记住:_; 的位置决定了原函数代码插入的位置。通常放在修饰符的最后。

💡 个人经验: 我建议在修饰符里尽量少写复杂逻辑,尤其是循环和外部调用。因为修饰符的 gas 消耗会计入调用它的函数,容易导致 gas 估算不准。

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,那么最终调用哪个取决于继承顺序。我建议:尽量保持继承链简单,不要超过三层。我在审计中见过一个项目,继承链有七层,结果一个函数调用绕了三个合约才找到实现——这种代码几乎没法审计。

⚠️ 避坑指南: 我曾经审计过一个 DAO 合约,它继承了多个抽象合约,但有一个函数在父合约里是 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 的上下文都搞不懂,怎么审计代理合约?

下一章,我们会进入真正的安全审计实战。我会带着你从零开始分析一个真实的漏洞案例。嗯,到时候你会看到,这些基础概念是如何在实战中发挥作用的。