【区块链安全 | 第十七篇】类型之引用类型(一)

文章目录

  • 引用类型
    • 数据存储位置
    • 数组
      • [特殊数组:bytes 和 string 类型](#特殊数组:bytes 和 string 类型)
      • [bytes.concat 和 string.concat 的功能](#bytes.concat 和 string.concat 的功能)
      • [分配 memory 数组](#分配 memory 数组)
      • [数组字面量(Array Literals)](#数组字面量(Array Literals))
      • 二维数组字面量
      • [数组成员(Array Members)](#数组成员(Array Members))
      • [悬空引用(Dangling References)到存储数组元素](#悬空引用(Dangling References)到存储数组元素)

引用类型

引用类型的值可以通过多个不同的名称进行修改。这与值类型形成对比,在值类型中,每当使用一个值类型的变量时,都会获得一个独立的副本。因此,引用类型比值类型需要更谨慎地处理。目前,引用类型包括结构体(structs)、数组(arrays)和映射(mappings)。如果使用引用类型,必须明确提供数据存储的位置:memory(其生命周期仅限于外部函数调用期间)、storage(存储状态变量的位置,其生命周期与合约的生命周期一致)或 calldata(一个特殊的数据存储区域,其中包含函数参数)。

如果赋值或类型转换导致数据存储位置发生变化,则会自动触发复制操作,而在同一数据存储位置内部进行赋值时,仅在某些情况下会触发复制(对于 storage 类型)。

数据存储位置

每个引用类型都有一个额外的注释,即"数据存储位置",用于指明其存储位置。数据存储位置包括 memory、storage 和 calldata。calldata 是一个不可修改、不可持久化的区域,其中存储了函数参数,其行为大多数情况下类似于 memory。

注意

transient 作为引用类型的数据存储位置目前尚不受支持。 如果可能,尽量使用 calldata 作为数据存储位置,因为这样可以避免复制,同时确保数据不可修改。具有 calldata 存储位置的数组和结构体可以作为函数的返回值,但无法直接分配此类类型。

注意

在函数体中声明或作为返回参数的 calldata 位置的数组和结构体必须在使用或返回之前进行赋值。在某些使用非平凡控制流的情况下,编译器可能无法正确检测初始化。在这些情况下,一个常见的解决方法是先将受影响的变量赋值给自身,然后再进行正确的初始化。

注意

在 0.6.9 版本之前,外部函数的引用类型参数数据存储位置仅限于 calldata,公共函数为 memory,内部和私有函数则可以是 memory 或 storage。而现在,所有可见性(visibility)的函数都允许使用 memory 和 calldata。

注意

构造函数的参数不能使用 calldata 作为数据存储位置。

注意

在 0.5.0 版本之前,数据存储位置可以省略,并且会根据变量类型、函数类型等默认使用不同的位置。但从 0.5.0 版本开始,所有复杂类型都必须显式指定数据存储位置。

分配行为

数据存储位置不仅与数据的持久性相关,还会影响赋值的语义:

  • 在 storage 和 memory(或 calldata)之间的赋值总是会创建一个独立的副本。
  • 在 memory 之间的赋值仅创建引用。这意味着对一个 memory 变量的修改会影响所有引用同一数据的 memory 变量。
  • 从 storage 赋值给本地 storage 变量时,也只是赋值引用。
  • 其他所有对 storage 的赋值都会进行复制。例如,对状态变量的赋值,或对 storage 结构体类型的本地变量的成员赋值,即使本地变量本身只是一个引用。

举个例子:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.5.0 <0.9.0;

contract C {
    // x 的数据存储位置是 storage。
    // 这是唯一可以省略数据存储位置的地方。
    uint[] x;

    // memoryArray 的数据存储位置是 memory。
    function f(uint[] memory memoryArray) public {
        x = memoryArray; // 可以执行,会复制整个数组到 storage
        uint[] storage y = x; // 可以执行,赋值的是指针,y 的数据存储位置是 storage
        y[7]; // 合法,返回第 8 个元素
        y.pop(); // 合法,通过 y 修改 x
        delete x; // 合法,清空数组,同时影响 y

        // 以下操作无法执行,因为它需要在 storage 中创建一个新的临时/匿名数组,
        // 但 storage 是静态分配的:
        // y = memoryArray;

        // 同样,"delete y" 也是不合法的,因为对指向 storage 对象的本地变量的赋值
        // 只能来自已有的 storage 对象。
        // 它会"重置"指针,但没有合理的位置可以指向。
        // 更多细节请参考"delete" 运算符的文档。
        // delete y;

        g(x); // 调用 g,传递 x 的引用
        h(x); // 调用 h,创建一个独立的临时副本存储在 memory
    }

    function g(uint[] storage) internal pure {}
    function h(uint[] memory) public pure {}
}

数组

数组可以是编译时固定大小的,也可以是动态大小的。

固定大小为 k,元素类型为 T 的数组写作 T[k],而动态大小的数组写作 T[]

例如,一个包含 5 个 uint 动态数组的数组写作 uint[][5]。该表示法与某些其他语言相反。在 Solidity 中,X[3] 始终是一个包含 3 个 X 类型元素的数组,即使 X 本身是一个数组。而在 C 语言等其他语言中,这种情况可能不同。

索引从 0 开始,访问顺序与声明顺序相反。

例如,如果有一个变量 uint[][5] memory x,要访问第三个动态数组中的第七个 uint,应使用 x[2][6],而访问第三个动态数组则使用 x[2]。同样,如果有一个数组 T[5] a,其中 T 也可以是数组,则 a[2] 的类型始终为 T

数组元素可以是任何类型,包括 mappingstruct。但一般的类型限制仍然适用,例如 mapping 只能存储在 storage 数据位置,并且 public 可见性的函数参数必须是 ABI 类型。

可以将状态变量数组标记为 public,Solidity 会自动为其创建一个 getter。数值索引会成为 getter 的必填参数。

访问超出数组末尾的索引会导致断言失败(Assertion Failure)。

动态大小数组的 push()push(value) 方法可用于在数组末尾追加新元素:

  • .push() 追加一个零初始化的元素,并返回对该元素的引用。
  • .push(value) 追加指定值的元素。

注意

动态数组只能在 storage 中调整大小。在 memory 中,此类数组可以是任意大小,但一旦分配后,其大小就无法更改。

特殊数组:bytes 和 string 类型

bytesstring 类型是特殊的数组。bytes 类型类似于 bytes1[],但在 calldatamemory 中会进行紧密打包(packed)。string 等同于 bytes,但不允许使用长度或索引访问。

Solidity 不提供字符串操作函数,但可以使用第三方字符串库。也可以通过 keccak256(abi.encodePacked(s1)) == keccak256(abi.encodePacked(s2)) 来比较两个字符串的哈希值,或者使用 string.concat(s1, s2) 来连接两个字符串。

应优先使用 bytes 而非 bytes1[],因为 bytes 的开销更小。使用 bytes1[]memory 中存储时,每个元素之间会填充 31 个字节的填充数据(padding),而 storage 中由于紧密打包不存在填充。通常,bytes 适用于任意长度的原始字节数据,而 string 适用于任意长度的字符串(UTF-8 编码)。如果可以限制长度,应使用 bytes1bytes32 这样的值类型,因为它们的成本更低。

注意

如果想要访问字符串 s 的字节表示,可使用 bytes(s).length 获取长度,或 bytes(s)[7] = 'x'; 进行修改。但这样访问的是 UTF-8 编码的底层字节,而不是独立的字符。

bytes.concat 和 string.concat 的功能

string.concat 可用于连接任意数量的 string 值。该函数返回一个 memory 位置的 string,其中包含所有参数的内容,不包含填充(padding)。如果参数的类型不能隐式转换为 string,需要先进行转换。

类似地,bytes.concat 可用于连接任意数量的 bytesbytes1bytes32 类型的值。该函数返回一个 memory 位置的 bytes,其中包含所有参数的内容,不包含填充。如果参数是 string 或其他不能隐式转换为 bytes 的类型,需要先转换为 bytesbytes1bytes32

示例如下:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;

contract C {
    string s = "Storage";

    function f(bytes calldata bc, string memory sm, bytes16 b) public view {
        // 连接多个字符串
        string memory concatString = string.concat(s, string(bc), "Literal", sm);
        assert((bytes(s).length + bc.length + 7 + bytes(sm).length) == bytes(concatString).length);

        // 连接多个字节数组
        bytes memory concatBytes = bytes.concat(bytes(s), bc, bc[:2], "Literal", bytes(sm), b);
        assert((bytes(s).length + bc.length + 2 + 7 + bytes(sm).length + b.length) == concatBytes.length);
    }
}

如果调用 string.concat()bytes.concat() 时不传递任何参数,则返回一个空数组。

分配 memory 数组

可以使用 new 关键字创建动态长度的 memory 数组。与 storage 数组不同,memory 数组无法调整大小(例如,push() 方法不可用)。因此,必须在创建时确定所需大小,或创建一个新数组并复制所有元素。

与 Solidity 中的所有变量一样,新分配的数组元素总是初始化为默认值。

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    function f(uint len) public pure {
        uint ; // 创建一个长度为 7 的 uint 数组
        bytes memory b = new bytes(len); // 创建一个长度为 len 的 bytes 数组
        assert(a.length == 7);
        assert(b.length == len);
        a[6] = 8; // 赋值
    }
}

数组字面量(Array Literals)

数组字面量是用 [...] 包裹的一个或多个逗号分隔的表达式。例如:[1, a, f(3)]

数组字面量的类型遵循以下规则:

  • 它总是一个 静态大小的 memory 数组,其长度等于表达式的个数。
  • 其基本类型是列表中 第一个表达式 的类型,并且其他所有表达式都必须能隐式转换为该类型。如果无法转换,则会报错。
  • 仅仅存在一个可以转换的共同类型是不够的,必须有一个元素的原始类型就是该类型。

示例:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    function f() public pure {
        g([uint(1), 2, 3]); // 显式转换第一个元素的类型
    }
    function g(uint[3] memory) public pure {
        // ...
    }
}

在上例中,[1, 2, 3] 的类型是 uint8[3] memory,因为 123 默认都是 uint8 类型。如果想让其成为 uint[3] memory,需要将第一个元素转换为 uint

无效示例

solidity 复制代码
[1, -1] // 无效,1 是 uint8,-1 是 int8,无法隐式转换

有效示例

solidity 复制代码
[int8(1), -1] // 有效,所有元素都是 int8

二维数组字面量

不同类型的定长 memory 数组之间无法相互转换,即使它们的基本类型可以转换。因此,在使用二维数组字面量时,必须显式指定共同的基本类型。

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

// 声明合约 C
contract C {
    /**
     * @dev 返回一个 4 行 2 列的 `uint24` 类型的二维静态数组
     * @return x 返回的数组 `uint24[2][4] memory`
     */
    function f() public pure returns (uint24[2][4] memory) {
        // 定义一个 `uint24[2][4]` 类型的二维静态数组,并初始化
        uint24[2][4] memory x = [
            [uint24(0x1), 1],      // 第一行: 0x1(16 进制)转换为 uint24,第二个元素为 1
            [0xffffff, 2],         // 第二行: 0xffffff(最大 uint24 值),第二个元素为 2
            [uint24(0xff), 3],     // 第三行: 0xff(255),第二个元素为 3
            [uint24(0xffff), 4]    // 第四行: 0xffff(65535),第二个元素为 4
        ];
        
        // 返回二维数组
        return x;
    }
}

如果没有显式指定 uint24,如下代码会 报错

solidity 复制代码
uint[2][4] memory x = [[0x1, 1], [0xffffff, 2], [0xff, 3], [0xffff, 4]];

固定大小的 memory 数组不能赋值给动态大小的 memory 数组,示例如下:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.0 <0.9.0;

// 这段代码无法编译
contract C {
    function f() public {
        uint[] memory x = [uint(1), 3, 4]; // 无法将 `uint[3] memory` 赋值给 `uint[] memory`
    }
}

未来可能会移除此限制,但由于 ABI 传递数组的方式,此限制目前仍然存在。

如果要初始化动态大小的数组,则必须分配各个元素:

solidity 复制代码
// 使用 new 关键字创建动态数组并手动赋值
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.4.16 <0.9.0;

contract C {
    function f() public pure {
        uint[] memory x = new uint[](3);
        x[0] = 1;
        x[1] = 3;
        x[2] = 4;
    }
}

数组成员(Array Members)

length:

数组有一个 length 成员,它包含数组的元素数量。

内存(memory)数组的长度在创建后是固定的(但在运行时可以动态确定)。

push():

  • 动态存储(storage)数组bytes (但不包括 string)有一个 push() 成员函数,
    你可以使用它在数组末尾追加一个 零初始化 的元素。
  • 它返回对新元素的引用,因此可以像 x.push().t = 2x.push() = b 这样使用。

push(x):

  • 动态存储(storage)数组bytes (但不包括 string)有一个 push(x) 成员函数,
    你可以使用它在数组末尾追加指定的元素。
  • 该函数不返回任何值。

pop():

  • 动态存储(storage)数组bytes (但不包括 string)有一个 pop() 成员函数,
    你可以使用它从数组末尾移除一个元素。
  • 这也会 隐式调用 delete 来清除被移除的元素。
  • 该函数不返回任何值。

注意

通过调用 push() 增加存储数组的长度具有 恒定的 gas 费用,因为存储在 Solidity 中默认会被初始化为零。

但通过 pop() 减少存储数组的长度的费用 取决于被移除元素的大小 。如果被移除的元素是 数组 ,则会非常昂贵,因为它的删除行为类似于调用 delete 来显式清除所有被移除的元素。

注意

要在external(而不是(public))函数中使用数组的数组(arrays of arrays),你需要启用 ABI 编码器 v2(ABI coder v2)。

在Byzantium 之前的 EVM 版本,无法访问从函数调用返回的动态数组。如果你的函数返回动态数组,请确保你的 EVM 版本设置为Byzantium 或更高版本。

悬空引用(Dangling References)到存储数组元素

在操作存储数组时,需要小心避免悬空引用。悬空引用是指指向某个已经不存在或者已被移动但没有更新引用的元素的引用。

例如,悬空引用可能发生在您将一个数组元素的引用存储到一个局部变量中,然后对包含该元素的数组进行 .pop() 操作时:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;

contract C {
    uint[][] s;

    function f() public {
        // 存储对 s 最后一个数组元素的指针。
        uint[] storage ptr = s[s.length - 1];
        // 删除 s 的最后一个数组元素。
        s.pop();
        // 尝试对不再数组中的元素进行写操作。
        ptr.push(0x42);
        // 之后对 s 进行 push 操作时,不会添加一个空数组,
        // 而是会导致 s 的最后一个元素长度为 1,且其第一个元素是 0x42。
        s.push();
        assert(s[s.length - 1][0] == 0x42);
    }
}

在上述代码中,ptr.push(0x42) 不会回滚,尽管 ptr 已不再指向一个有效的 s 数组元素。因为编译器假设未使用的存储区域始终被零化,因此后续的 s.push() 操作不会显式地将零写入存储空间,导致 s 的最后一个元素在该 push() 后的长度为 1,并且第一个元素是 0x42。

需要注意的是,Solidity 不允许声明对值类型(如 uintbool 等)的存储引用。这类显式的悬空引用仅限于嵌套引用类型。但是,在使用复杂表达式进行元组赋值时,暂时也可能会产生悬空引用:

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;

contract C {
    uint[] s;
    uint[] t;
    constructor() {
        // 向存储数组中推送一些初始值。
        s.push(0x07);
        t.push(0x03);
    }

    function g() internal returns (uint[] storage) {
        s.pop();
        return t;
    }

    function f() public returns (uint[] memory) {
        // 以下代码首先会评估 `s.push()` 作为对新元素索引 1 的引用,
        // 然后调用 `g` 函数将此新元素弹出,导致左侧的元组元素成为悬空引用。
        // 尽管如此,赋值仍然会进行,并且会写入 `s` 数据区外。
        (s.push(), g()[0]) = (0x42, 0x17);
        // 随后对 `s` 进行 push 操作时,会暴露上一条语句写入的值,
        // 即函数结束时 `s` 的最后一个元素的值为 0x42。
        s.push();
        return s;
    }
}

在编写代码时,为了安全起见,最好每次赋值时只操作一次存储,并避免在赋值语句的左侧使用复杂表达式。

当操作字节数组(bytes array)元素的引用时,您需要特别小心,因为对字节数组执行 .push() 操作时,可能会导致存储布局从短格式转换为长格式。

solidity 复制代码
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.0 <0.9.0;

// 这段代码会报告警告
contract C {
    bytes x = "012345678901234567890123456789";

    function test() external returns(uint) {
        (x.push(), x.push()) = (0x01, 0x02);
        return x.length;
    }
}

在这里,当第一次执行 x.push() 时,x 仍然以短格式存储,因此 x.push() 返回的是 x 第一个存储槽中的元素的引用。然而,第二次执行 x.push() 时,字节数组的存储格式会变成长格式。此时,x.push() 所引用的元素已经被移动到数组的数据区域,而引用仍指向原始位置(即长度字段)。因此,赋值操作会有效地破坏 x 的长度字段。

为了安全起见,建议在单次赋值语句中,只增加字节数组最多一个元素,并且不要同时在同一语句中进行数组的索引访问。

虽然上述描述的是当前版本编译器中的悬空存储引用的行为,但任何包含悬空引用的代码都应被视为具有未定义行为。因此,需确保在编写代码时避免悬空引用。

相关推荐
神经毒素1 小时前
WEB安全--文件上传漏洞--一句话木马的工作方式
网络·安全·web安全·文件上传漏洞
杭州默安科技3 小时前
大模型AI Agent的工作原理与安全挑战
人工智能·安全
rockmelodies9 小时前
OpenSCAP 是一个基于开源的安全合规性自动化框架
安全·开源·自动化
赴前尘9 小时前
Go+Gin实现安全多文件上传:带MD5校验的完整解决方案
安全·golang·gin
IT成长日记9 小时前
Elasticsearch安全加固指南:启用登录认证与SSL加密
安全·elasticsearch·ssl
半路_出家ren9 小时前
网络安全设备介绍:防火墙、堡垒机、入侵检测、入侵防御
安全·网络安全·负载均衡·堡垒机·防火墙·网络安全设备·上网行为管理
IT程序媛-桃子10 小时前
【网安面经合集】42 道高频 Web 安全面试题全解析(附原理+防御+思路)
运维·网络·安全·面试
予安灵10 小时前
《白帽子讲 Web 安全》之服务端请求伪造(SSRF)深度剖析:从攻击到防御
前端·安全·web安全·网络安全·安全威胁分析·ssrf·服务端请求伪造
蒜白10 小时前
27--当路由器学会“防狼术“:华为设备管理面安全深度解剖(完整战备版)
网络·安全·网络工程师·交换机
碧海饮冰10 小时前
Crypto加密货币生态构成及较有前景的几个crypto项目
区块链