solidity&blockchain初探
solidity这个语言广泛运用与智能合约的编写,想要入门区块链就得先了解这个语言。该语言其实与c语言用法类似。在solidity语言中,标识执行账户是用地址实现的,比如我们MetaMask中创建的Account的地址或是某个合约的地址。
1.一些基本概念与工具站
1)账户
外部账户
外部账户是由人创建的,可以存储以太币,是由公钥和私钥控制的账户。每个外部账户拥有一对公私钥,这对密钥用于签署交易,它的地址由公钥决定。外部账户不能包含以太坊虚拟机(EVM)代码。
一个外部账户具有以下特性
- 拥有一定的 Ether
- 可以发送交易、通过私钥控制
- 没有相关联的代码
合约账户
合约账户是由外部账户创建的账户,包含合约代码。合约账户的地址是由合约创建时合约创建者的地址,以及该地址发出的交易共同计算得出的。
一个合约账户具有以下特性
- 拥有一定的 Ether
- 有相关联的代码,代码通过交易或者其他合约发送的调用来激活
- 当合约被执行时,只能操作合约账户拥有的特定存储
2)合约
在区块链和智能合约的上下文中,合约通常是指一种程序或协议,能够在区块链上自动执行、控制或文档化法律事件和行动。以下是合约的一些关键特征:
- 智能合约:智能合约是一种自执行的合约,其中协议的条款以代码形式写入,运行在区块链上。它们能够自动执行合约条款,减少对中介的依赖。
- 去中心化:合约在区块链上运行,没有单一控制点,这使得合约更加透明和安全。
- 不可篡改性:一旦合约部署到区块链上,其内容就无法更改,这提供了强有力的防篡改保障。
- 透明性:合约的代码和执行是公开的,任何人都可以查看,从而提高了信任度。
- 自动执行:合约可以根据预设条件自动执行,省去人工干预的需要。例如,当某个条件被满足时,合约会自动转移资产。
- 多种用途:合约可以用于多种场景,如金融交易、身份验证、供应链管理、投票系统等。
3)常用网站及插件
- MetaMask插件:可以创建属于自己的以太网账户,拥有一个地址,其相当于一个钱包,存着你不同网络中的以太币。
- Remix:一个在线IDE,用于编辑合约以及与合约交互。
- Fauctes:水龙头,可以用来免费获取测试网络中免费的货币。
- infura:可以获取apikey,用于web3开发测试。
2.存储类型
Solidity 主要有三种存储类型:
- storage:永久存储,存储在区块链上,所有合约状态变量(如
uint256 totalSupply;
)都是存储在这里。每次修改都会消耗 gas。 - memory:临时存储,存储在内存中,生命周期仅在当前调用期间。函数调用结束后,数据会被清除。适用于需要临时使用的数据,如函数内部的计算结果。
- calldata:用于函数参数的只读数据存储位置,数据存在于外部调用时的输入中,通常用于优化 gas 使用。
3.修饰符
1)访问修饰符
public
:函数或变量可以被任何合约或外部账户访问。private
:函数或变量只能在定义它的合约内部访问,其他合约无法访问。internal
:函数或变量只能在当前合约及其子合约中访问,外部合约无法访问。external
:函数只能被外部账户或其他合约调用,不能在合约内部调用。
2)状态修饰符
view
:函数不会修改区块链状态,且可以读取合约的状态变量。调用此函数不会消耗 gas。pure
:函数不读取或修改任何状态变量,也不访问任何合约的状态。它只能使用传入的参数。调用此函数同样不会消耗 gas。payable
:函数可以接收 ETH。用于处理涉及资金转移的功能。
3)其他常用修饰符
require
:用于验证条件是否为真,如果条件不满足,则抛出异常并撤销交易。常用于输入验证和权限检查。assert
:用于检查不应发生的条件,如果条件不满足,则抛出异常并撤销交易。通常用于内部错误和不变性验证。revert
:显式撤销交易,并可以返回错误消息。与require
类似,但可以用于更复杂的条件检查。
4)自定义修饰符
常见的是用于权限控制。
1 | modifier onlyOwner { |
5)Fallback 函数
特殊的函数,当合约接收到 ETH 但没有匹配的函数调用时会被执行。可以用于接收资金。
4.常用内置函数以及全局变量
abi.encodePacked
是 Solidity 中的一个内置函数,用于将多个参数编码为一个字节数组。它在处理数据时非常有用,特别是在需要进行哈希计算、合约交互或其他数据处理时。Keccak256
: 将输入数据(无论大小)转换为固定长度的输出(256 位),即 32 字节的哈希值。msg.sender
:指向当前运行合约账户的地址tx.origin
:存着整个调用链最原始的调用者的地址,及交易的原始发起方
5.从示例看基础语法
example:
1 | // WelcomeSHCTF2024.sol |
pragma solidity ^0.8.0;
:指明合约是用 Solidity 编写的,并且要求编译器版本为 0.8.0 或更高。contract
:中文译为合约,类似与class,实际上就是声明一个对象。string private storedFlag
:这个就很熟悉了,就是类型+访问修饰符+变量名的组合,声明一个变量constructor(params){}
:这个就是该合约的构造函数,在创建时会接受参数并初始化function
:声明一个方法,参数可以带上修饰符,后面也可以跟上若干修饰符
1 | // SPDX-License-Identifier: MIT |
我们也可以写一个与上述实例相交互的脚本,这里我们假设上面的storedFlag是个public变量,其中要先定义一个interface
接口,里面要写这个接口中能被外部调用的方法,也就是有external或public修饰的方法,其具体定义可以直接复制源码中方法的定义。这样定义之后,我们就可以通过传入合约实例的地址,来创建这么一个接口实例,然后就能调用该实例对外公开的方法。要访问属性的话要通过getter方法,也就是多的一句function storedFlag() external view returns (string memory);
来实现属性的接口调用。我们可以在本地做实验以验证。
remix合约部署
可以先创建一个新的工作区,选择default
project就行。然后在contracts目录下新建自己合约文件,如WelcomeSHCTF2024.sol
以及exp.sol
,然后选择编译器版本后进行编译,之后转到Deploy & run transactions
界面。


在环境上选Remix VM(与实际的测试网交互要选WalletConnect来连接到自己的账户),然后我们可以在deploy部署前输入一个flag字符串,作为该合约的constructor的参数。点击deploy进行合约的部署。

部署成功后底下Deployed Contracts会显示出内容,我们可以通过点击按钮来调用各个接口,有些接口的调用我们需要传参。可以发现我们的public变量也可以作为接口调用,点击storedFlag就能获取到原合约异或加密后的storedFlag的值


然后我们复制一下这个自己部署的合约的地址,在编译完exp.sol后,在CONTRACT中选择exp.sol,传入刚才部署的合约地址用以接口调用。

成功以后,用我们之前编写的getFlag外部方法,就能直接获取到我们之前部署的flag了,这样能够获取到public存储的flag密文。

6.存储层面
这个感觉在ctf解题中是很重要的,感觉ctf-wiki中已经讲的很好了,这里我再整理一遍吧。
插槽
以太坊数据存储会为合约的每项数据指定一个可计算的存储位置,存放在一个容量为 2^256 的超级数组中,数组中每个元素称为插槽,其初始值为 0。虽然数组容量的上限很高,但实际上存储是稀疏的,只有非零 (空值) 数据才会被真正写入存储。
1 | # 插槽式数组存储 |
当数据长度是已知时,其具体的存储位置将在编译时指定,而对于长度不确定的类型(如动态数组、映射),则会按一定规则计算存储位置。以下是对不同类型变量的储存模型的具体分析。
存储规则
- 存储插槽以低位对齐方式存储,在图上直观表示就是右对齐
- 每个基本类型只占存储它们所需字节
- 一个插槽内能存多个类型
- 如果存储插槽中的剩余空间不足以储存一个基本类型,那么它会被移入下一个存储插槽
- 结构和数组数据总是会占用一整个新插槽(但结构或数组中的各项,都会以这些规则进行打包)
一般存法
如以下合约:
1 | pragma solidity ^0.4.0; |
其存储布局如下:
1 | ----------------------------------------------------- |
动态数组存法
会占用对应位置 p
处的插槽,用以储存数组的长度,而数组真正的起始点会位于
keccak256(p)
处
如以下合约:
1 | pragma solidity ^0.4.0; |
其存储布局如下:
1 | ----------------------------------------------------- |
字节数组和字符串存法
如果 bytes
和 string
的数据很短,那么它们的长度也会和数据一起存储到同一个插槽。具体地说:如果数据长度小于等于
31 字节, 则它存储在高位字节(左对齐),最低位字节存储
length * 2
。如果数据长度超出 31 字节,则在主插槽存储
length * 2 + 1
, 数据照常存储在
keccak256(slot)
中。
映射存法
对于映射,其会占据位置 p
处的一个插槽,但该插槽不会被真正使用。映射中的键 k
所对应的值会位于 keccak256(k . p)
, 其中 .
是连接符。如果该值同时是一个非基本类型,则将
keccak256(k . p)
作为偏移量来找到具体的位置。
如以下合约:
1 | pragma solidity ^0.4.0; |
其存储布局如下:
1 | ----------------------------------------------------- |
不同类型所占字节数
X={8,16,24,32,40,48,56,64,128,256} N={x|1<=x<=16,x=32}
表中{x}代表X集合中某个元素,{n}表示N集合中某个元素,???为动态类型数据
类型 | 大小(字节) |
---|---|
address | 20 |
address payable | 20 |
bool | 1 |
uint{x} | {x}/8 |
int{x} | {x}/8 |
bytes{n} | {n} |
bytes(动态字节数组) | ??? |
string(动态字符串) | ??? |
结构体、数组、映射 | ??? |
7.题目分析
[SHCTF] just Signin

先看题目提供的合约,其中有存了一个flag,然后我们可以从外部调用verifyXORedFlag来验证我们输入的flag是否正确,但显然我们不可能直接去爆破flag的值,这时候我们参考ctf-wiki中关于Ethereum Storage
的介绍。
由于以太坊上的所有信息都是公开的,所以即使一个变量被声明为 private,我们仍能读到变量的具体值。
利用 web3 提供的
web3.eth.getStorageAt()
方法,可以读取一个以太坊地址上指定位置的存储内容。所以只要计算出了一个变量对应的插槽位置,就可以通过调用该函数来获得该变量的具体值。`
那么我们就可以去用web3这个js库编写脚本来分析插槽内容。根据内存存储的规则,string类型的变量是动态分配内存的,由于存的内容大于31字节,所以该位置slot0会存大小,而keccak256(0)中会存实际内容。而且由于内容大于32字节,所以我们要连续读几个插槽,这里读两个就能读到全部内容了。
在写脚本过程中还遇到了几个坑:
- 直接keccak256(0)出来的插槽位置中是全空的,实际上我们要得到的插槽位置在keccak256(abi.encodePacked(0))中,也就是在keccak256('0x0000000000000000000000000000000000000000000000000000000000000000')中。
- 其中用npm装web3库时后面测试运行时会报错,原因是我拿apt装的nodejs版本较低,解决方法是拿nvm重装高版本nodejs。
const web3 = new Web3("https://sepolia.infura.io/v3/your_api");
这个创建实例一开始我后面的url不知道填什么,从ChainList找了几个url填进去,然后会发现对应地址处的插槽是全空的,显然是找错链了。后面在infura注册后用里面的测试网络sepolia能够正确找到对应合约。
1 | // const Web3 = require('web3'); |

然后我们就能得到经过异或加密的密文,简单解密回去后就能得到flag
python解密exp
1 | enc = [32, 32, 32, 32, 32, 8, 31, 6, 69, 5, 28, 5, 6, 43, 18, 28, 55, 23, 28, 85, 44, 10, 82, 27, 5, 24, 43, 11, 21, 15, 29, 55, 20, 68, 20, 31, 12, 30] |
- 标题: solidity&blockchain初探
- 作者: collectcrop
- 创建于 : 2024-10-08 00:04:22
- 更新于 : 2024-10-08 00:09:20
- 链接: https://collectcrop.github.io/2024/10/08/solidity-blockchain初探/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。