前言
工作开始转向区块链安全研究,打算好好学习一下以太坊
正好宇哥让写篇短地址攻击的文章,借此按短地址攻击的线索读了下EVM的源码,收获挺多
PS:这篇博客快要写完的时候差点误删了…还好点清空废纸篓前抬了一手意识到有点不对劲…
简介
EVM
EVM(Ethereum Virtual Machine),以太坊虚拟机的简称,是以太坊的核心之一。智能合约的创建和执行都由EVM来完成,简单来说,EVM是一个状态执行的机器,输入是solidity编译后的二进制指令和节点的状态数据,输出是节点状态的改变
短地址攻击
以太坊短地址攻击,是由于底层EVM的设计缺陷导致的漏洞
ERC20代币标准定义的transfer函数如下:
function transfer(address to, uint256 value) public returns (bool success)
如果传入的to是末端缺省的短地址,EVM会将后面字节补足地址,而最后的value值不足则用0填充,导致实际转出的代币数值倍增
EVM源码分析
evm.go
EVM的源码位于go-ethereum/core/vm/目录下,在evm.go中定义了EVM结构体,并实现了EVM.Call、EVM.CallCode、EVM.DelegateCall、EVM.StaticCall四种方法来调用智能合约,EVM.Call实现了基本的合约调用的功能,后面三种方法与EVM.Call略有区别,但最终都调用run函数来解析执行智能合约

run函数前半段是判断是否是以太坊内置预编译的特殊合约,有单独的运行方式
后半段则是对于一般的合约调用解释器interpreter去执行调用

interpreter.go
解释器相关代码在interpreter.go中,interpreter是一个接口,目前仅有EVMInterpreter这一个具体实现

合约经由EVM.Call调用Interpreter.Run来到EVMInpreter.Run
EVMInterpreter的Run方法代码较长,缩略代码如下:
func (in *EVMInterpreter) Run(contract *Contract, input []byte, readOnly bool) (ret []byte, err error) {
if in.intPool == nil {...} //创建intPool用于分配big.Int,减少频繁创建销毁big.Int的开销
//evm.depth用于记录合约的递归层数
in.evm.depth++
defer func() { in.evm.depth-- }()
//确保仅在尚未设置readOnly的情况下设置为readOnly
if readOnly && !in.readOnly {...}
in.returnData = nil
if len(contract.Code) == 0 {return nil,nil}
var (
op OpCode //操作码指令
mem = NewMemory() //内存
stack = newstack() //栈
pc = uint64(0) //程序计数器,program counter
...
res []byte //指令执行结果
)
contract.Input = input//input调用参数传入contract.Input
defer func() { in.intPool.put(stack.data...) }()
if in.cfg.Debug {...} //debug模式下跟踪捕获状态和错误
//主循环
for atomic.LoadInt32(&in.evm.abort) == 0 {
... //循环解析执行合约的字节码
}
return nil, nil
}
EVMInterpreter.Run方法中处理执行合约字节码的主循环如下:

大部分代码主要是检查准备运行环境,执行合约字节码的核心代码主要是以下3行
op = contract.GetOp(pc)
operation := in.cfg.JumpTable[op]
......
res, err = operation.execute(&pc, in, contract, mem, stack)
......
interpreter的主要工作实际上只是通过JumpTable查找指令,起到一个翻译解析的作用
最终的执行是通过调用operation对象的execute方法
jump_table.go
operation的定义位于jump_table.go中

jump_table.go中还定义了JumpTable和多种不同的指令集

在interpreter.go创建解释器的NewEVMInterpreter函数中,会根据以太坊版本选择相应的指令集

在基本指令集中有三个处理input的指令,分别是CALLDATALOAD、CALLDATASIZE和CALLDATACOPY

jump_table.go中的代码同样只是起到解析的功能,提供了指令的查找,定义了每个指令具体的执行函数
instructions.go
instructions.go中是所有指令的具体实现,上述三个函数的具体实现如下:

这三个函数的作用分别是从input加载参数入栈、获取input大小、复制input中的参数到内存
我们重点关注opCallDataLoad函数是如何处理input中的参数入栈的
opCallDataLoad函数调用getDataBig函数,传入contract.Input、stack.pop()和big32,将结果转为big.Int入栈

getDataBig函数以stack.pop()栈顶元素作为起始索引,截取input中big32大小的数据,然后传入common.RightPadBytes处理并返回
其中涉及到的另外两个函数math.BigMin和common.RightPadBytes如下:
//file: go-thereum/common/math/big.go
func BigMin(x, y *big.Int) *big.Int {
if x.Cmp(y) > 0 {
return y
}
return x
}
//file: go-ethereum/common/bytes.go
func RightPadBytes(slice []byte, l int) []byte {
if l <= len(slice) {
return slice
}
//右填充0x00至l位
padded := make([]byte, l)
copy(padded, slice)
return padded
}
分析到这里,基本上已经能很明显看到问题所在了
RightPadBytes函数会将传入的字节切片右填充至l位长度,而l是被传入的big32,即32位长度
所以在短地址攻击中,调用的transfer(address to, uint256 value)函数,如果to是低位缺省的地址,由于EVM在处理时是固定截取32位长度的,所以会将value数值高位补的0算进to的末端,而在截取value时由于位数不够32位,则右填充0x00至32位,最终导致转账的value指数级增大
测试与复现
编写一个简单的合约来测试
pragma solidity ^0.5.0;
contract Test {
uint256 internal _totalSupply;
mapping(address => uint256) internal _balances;
event Transfer(address indexed from, address indexed to, uint256 value);
constructor() public {
_totalSupply = 1 * 10 ** 18;
_balances[msg.sender] = _totalSupply;
}
function totalSupply() external view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function transfer(address to,uint256 value) public returns (bool) {
require(to != address(0));
require(_balances[msg.sender] >= value);
require(_balances[to] + value >= _balances[to]);
_balances[msg.sender] -= value;
_balances[to] += value;
emit Transfer(msg.sender, to, value);
}
}
remix部署,调用transfer发起正常的转账

input为0xa9059cbb00000000000000000000000071430fd8c82cc7b991a8455fc6ea5b37a06d393f0000000000000000000000000000000000000000000000000000000000000001
直接尝试短地址攻击,删去转账地址的后两位,会发现并不能通过,remix会直接报错

这是因为web3.js做了校验,web3.js是用户与以太坊节点交互的媒介
源码复现
调试前面的正常调用,可以看到栈中已经压入了to和value两个参数

我们回退到压入第一个参数to的时候

CALLDATALOAD取栈顶0x04为起始索引截取input32字节数据0x00000000000000000000000071430fd8c82cc7b991a8455fc6ea5b37a06d393f,即为参数to
在取第二个参数时,先将栈顶下一位的0x04置于栈顶,压入0x20(即十进制32),执行与运算ADD

调整栈数据顺序后,以栈顶0x24为起始索引截取input32字节数据0x0000000000000000000000000000000000000000000000000000000000000001,即为参数value

至此,函数参数入栈流程已经清晰,通过源码函数复现如下:

实际复现
至于如何完成实际攻击,可以参考文末的链接[1],利用web3.eth.sendSignedTransaction绕过限制
实际上,web3.js做的校验仅限于显式传入转账地址的函数,如web3.eth.sendTransaction这种,像web3.eth.sendSignedTransaction、web3.eth.sendRawTransaction这种传入的参数是序列化后的数据的就校验不了,是可以完成短地址攻击的,感兴趣的可以自己尝试,这里就不多写了
PS:文中分析的go-ethereum源码版本是commit-fdff182,源码与最新版有些出入,但最新版的也未修复这种缺陷(可能官方不认为这是缺陷?),分析思路依然可以沿用
web3.js的校验
分析了下web3.js,更新这一小节来说明一下web3.js中相关的校验
简介
web3是一组用来和本地或远程以太坊节点进行交互的库,本质上是对以太坊节点暴露出来的JSON-RPC接口的封装,web3.js是其多个语言版本的实现之一
分析
web3.js对合约的调用是通过如下形式进行的:
contract_instance.methods.method_name.call()
contract_instance.methods.method_name.send()
其中contract_instance是合约的实例变量,method_name则是具体调用的合约方法
而call()和send()的区别则是:前者调用的是在合约中以pure/view声明的静态函数,不会改变合约状态;后者调用的是需要发起交易,会改变合约状态的函数
合约方法调用的相关代码在web3.js/packages/web3-eth-contract/src/index.js中

_executeMethod方法会先调用_processExecuteArguments对参数做处理

_processExecuteArguments函数主要构造调用的options,其中会调用this.encodeABI赋予options.data
this.encodeABI的定义在_createTxObject中,绑定的父类的_encodeMethodABI方法

_encodeMethodABI会调用abi.encodeParameters方法获取参数编码后的数据
而abi是在文件头部导入的web3-eth-abi包

跟进web3-eth-abi中的encodeParameters函数

大部分代码主要是对object和string类型的参数格式化处理,关键在最后返回的ethersAbiCoder.encode函数
在文件头部可以看到ethersAbiCoder是@ethersproject/abi包中的AbiCoder类的实例

跟进@ethersproject/abi包中AbiCoder类的encode方法

AbiCoder的encode方法中会先通过_getCoder获取编码器
在_getCoder函数中可以看到会根据参数的变量类型返回相应的编码器,其中针对地址类型的编码器AddressCoder位于./coders/address包中

来到coders/address.js中

AddressCoder.encode函数中直接尝试调用address_1.getAddress(),而address_1是导入的@ethersproject/address
我们来看@ethersproject/address中的getAddress函数

很明显,getAddress函数中会先对地址的形式做校验,正则中的{40}必须匹配到40位长度,匹配之后还会计算校验和,否则就会在上层encode中的try...catch语句中抛出错误Error: invalid address
另外除了getAddress函数中对地址形式的校验,encode函数在调用address_1.getAddress()之后,紧接着调用了writer.writeValue,而在writeValue函数中还会对地址参数进行左填充

测试
在node中导入web3.js
encodeFunctionCall

encodeParameters(改)

utils.isAddress

思考
以太坊底层EVM并没有修复短地址攻击的这么一个缺陷,而是直接在web3.js里对地址做的校验,目前各种合约或多或少也做了校验,所以虽然EVM底层可以复现,但实际场景中问题应该不大,但如果是开放RPC的节点可能还是会存在这种风险
另外还有一个点,按底层EVM的这种机制,易受攻击的应该不仅仅是transfer(address to, uint256 value)这个点,只是因为这个函数是ERC20代币标准,而且参数的设计恰好能导致涉及金额的短地址攻击,并且特殊的地址易构造,所以这个函数常作为短地址攻击的典型。在其他的一些非代币合约,如竞猜、游戏类的合约中,一些非转账类的事务处理函数中,如果不对类似地址这种的参数做长度校验,可能也存在类似短地址攻击的风险,也或者并不局限于地址,可能还有其他的利用方式还没挖掘出来
目前还没有找到一个好的其他函数的例子做演示,文章就先写到这,后面有新发现再更新
参考
[1] 以太坊短地址攻击详解
https://www.anquanke.com/post/id/159453
[2] 以太坊源码解析:evm