本文作者:小驹[1]
在计算机编程中,当在特定范围(代码块、方法或内部类)中声明的变量与在外部范围中声明的变量具有相同的名称时,就会发生变量隐藏
。变量隐藏在多种计算机语言中都存在,并不仅仅是 Solidity 语言独有的特性。
我们把这种同一个合约可能存在多个具有相同名称的变量,这种变量称为影子变量。
Solidity 支持继承,继承的引入可能会给 Solidity 的状态变量进行影子变量的问题。如想像一下这种场景:
带有变量 x 的合约 A 可以继承同样定义了状态变量 x 的合约 B,这将导致 x 变量 的两个不同版本。其中一个从合约 A 访问,另一个从合约 B 访问。
这种场景下极容易出现变量隐藏的安全问题。
在 Solidity 编码中,变量隐藏常出现的场景包括:
在编写更复杂的合约系统时,要时刻注意上面的两种场景,可能会由于忽视并随后导致变量隐藏安全问题。对于这两种场景分别用下面的代码进行演示。
pragma solidity 0.4.24;
contract ShadowingInFunctions {
uint n = 2;
uint x = 3;
function test1() constant returns (uint n) {
return n; // Will return 0
}
function test2() constant returns (uint n) {
n = 1;
return n; // Will return 1
}
function test3() constant returns (uint x) {
uint n = 4;
return n+x; // Will return 4
}
}
上面的三个函数的输出内容为:
instance deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
test1:0
test2:1
test3:4
在这段代码中,涉及到变量 n 的地方有多个,总结起来代码中变量 n 有三种变量类型,分别是:
在 test1 和 test2 函数中,返回值类型的 n 覆盖了合约的状态变量 n。所以函数返回的内容都是返回值类型的 n 的值。
在 test3 函数中,位于函数内的局部变量 n 覆盖了合约状态变量 n。所以函数返回的内容是局部变量计算出来的 n 的值。
可以分析下面的代码,看能否看出哪里有问题?
演示代码 Children.sol:
代码的本意是:Children 合约继承自 Base 合约,同时 Children 合约的 withdraw 取款操作具有 onlyOwner 修饰符,期望withdraw取款操作只能由Children合约的部署者
才能调用。
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.4.0;
import "hardhat/console.sol";
contract Base {
address public owner;
modifier onlyOwner() {
require(msg.sender==owner,"Only Owner can call the function");
_;
}
}
contract Children is Base {
uint public totalSupply = 100;
address public owner;
constructor() public {
owner = msg.sender;
console.log("\\nChildren constructor:%s",owner);
}
function withdraw(uint _amount) public onlyOwner {
totalSupply = totalSupply - _amount;
// console.log(“aaa”);
}
}
下面是 attack.ts 模拟的测试过程,通过 npx hardhat run script/attack.ts 进行测试。模拟的操作为:
user1部署Children合约
,并输出合约部署的地址,合约的 owner,totalSupply 信息。user1调用合约的withdraw方法
。import { ethers, waffle } from "hardhat";
async function main() {
let user1, user2;
[user1, user2] = await ethers.getSigners();
const Children = await ethers.getContractFactory("Children",user1);
const children = await Children.deploy();
await children.deployed();
console.log("deploy at:%s", children.address);
console.log("contract owner:%s", await children.owner());
console.log("totalSupply:%s", await children.totalSupply());
await children.connect(user1).withdraw(10);
console.log("totalSupply:%s", await children.totalSupply());
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
在 user1 调用 withdraw 方法时,却失败了。错误提示如下:
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 地址是 Children 合约的部署者,却无法调用 withdraw 方法。
返回源代码分析,Base 合约和 Children 合约都具有 owner 状态变量,却只有 Base 合约有 onlyOwner 修饰符。
这样的结果就是导致调用Children合约
的 withdraw 方法时,使用的是Base合约
的 onlyOwner 的修饰符,而 Base 合约的 onlyOwner 的修饰符检查的自然是Base合约的owner
,也就是 0x0000000000000000000000000000000000000000。因此就导致了 Children 合约无法调用 withdraw()方法。
所以说,能调用 Children 合约 withdraw()方法的地址只能是 0x0000000000000000000000000000000000000000 黑洞地址。
本质上还是因为父子合约中都使用了 owner 变量,使 owner 合约变成了影子变量,程序员在实现功能时混淆了 owner 变量的指代。
理解了上面的原因,很简单的两种修改方法:
给 Base 合约加上 owner 的赋值。
constructor() public {
owner = msg.sender;
console.log("\\nBase constructor:%s",owner);
}
给 Children 合约加上 OnlyOwner 修饰符。
modifier onlyOwner() {
require(msg.sender==owner,"Only Owner can call the function");
_;
}
变量隐藏常出现的两类场景:
不同特定作用范围的变量
。继承关系
的多个合约中,不同合约中具有相同名称的变量。对于场景 1, 在开发环境中,编辑器会提示如下的影子变量的风险。如在代码开发过程中,遇到下面的提示,建议移除影子变量或重命名影子变量。
对于 场景 2,编译器不会有影子变量的提示,更需要依赖于仔细检查您的合约代码的存储变量的定义,尽量消除任何歧义。
Smart Contract Weakness Classification and Test Cases 中对变量隐藏的解释
https://swcregistry.io/docs/SWC-119[2]
[1]
小驹: https://learnblockchain.cn/people/9625
[2]
https://swcregistry.io/docs/SWC-119: https://swcregistry.io/docs/SWC-119