0x00 前言
2018年08月01日,知道创宇404区块链安全研究团队发布《金钱难寐,大盗独行——以太坊 JSON-RPC 接口多种盗币手法大揭秘》,针对偷渡漏洞和后偷渡时代的盗币方式 进行了介绍,披露了后偷渡时代的三种盗币方式:离线攻击、重放攻击和爆破攻击。
在进一步的研究中,我们又发现了针对这些攻击方式的补充:拾荒攻击。攻击者或求助于矿工,或本身拥有一定算力以获得将交易打包进区块的权利。在偷渡漏洞中,攻击者在被攻击节点构造gasPrice 为 0 的交易,等待用户解锁账户签名广播。攻击者同时设置一个恶意节点,用于接收这笔交易。攻击者将符合条件的交易打包,就可以实现 0 手续费完成转账。通过这种攻击,攻击者可以获取到余额不足以支付转账手续费或勉强足够支付手续费节点上的所有以太币,并在一定程度上可以防止其他攻击者的竞争,可谓是 薅羊毛 的典范。
除此之外,在薅够以太币残羹之后,攻击者又盯上了这些以太币已被盗光,但账户中残留的代币。直到现在,针对许多智能合约发行的代币,一些被攻击账户中的token,仍在小额地被攻击者以拾荒攻击盗走。
本文将从一笔零手续费交易谈起,模拟复现盗币的实际流程,对拾荒攻击成功的关键点进行分析。
0x01 从一笔零手续费交易谈起
在区块链系统中,每一笔交易都应该附带一部分gas以及相应的gasPrice作为手续费,当该交易被打包进区块,这笔手续费将用来奖励完成打包的矿工。
在《金钱难寐,大盗独行——以太坊 JSON-RPC 接口多种盗币手法大揭秘》中,我们提到了一个利用以太坊JSON-RPC接口的攻击者账号0x957cD4Ff9b3894FC78b5134A8DC72b032fFbC464。该攻击者在公网中扫描开放的RPC端口,构造高手续费的交易请求,一旦用户解锁账户,便会将用户余额转至攻击者的账户或攻击者创建的合约账户。
在分析该账户交易信息的时候,我们发现了一笔不符合常识的交易,先从这笔交易开始谈起。
交易地址:0xb1050b324f02e9a0112e0ec052b57013c16156301fa7c894ebf2f80ac351ac22
Function: transfer(address _to, uint256 _value)MethodID: 0xa9059cbb[0]: 000000000000000000000000957cd4ff9b3894fc78b5134a8dc72b032ffbc464[1]: 000000000000000000000000000000000000000000000000000000000abe7d00
从0x00a329c0648769a73afac7f9381e08fb43dbea72向合约MinereumToken(攻击者的合约)的交易,虽然用户余额很少,但这笔交易使用了该账户所有余额作为value与合约交互,这笔交易使用了正常数量的gas,但它的gasPrice被设定为0。
前文提到,攻击者会使用较高的手续费来保证自己的交易成功,矿工会按照本节点的txpool中各交易的gasPrice倒序排列,优先将高gasPrice交易打包进之后的区块。在这个世界上每时每刻都在发生着无数笔交易,在最近七日,成交一笔交易的最低gasPrice是3Gwei。这笔零手续费交易究竟是如何发生,又是如何打包进区块的呢。
0x02 思路分析
在区块链系统中,任何人都可以加入区块链网络,成为其中一个节点,参与记账、挖矿等操作。保证区块链的可信性和去中心化的核心便是共识机制。
共识机制
在以太坊中,矿工将上一区块的哈希值、txpool中手续费较高的交易、时间戳等数据打包,不断计算nonce来挖矿,最先得出符合条件的nonce值的矿工将拥有记账权,得到手续费和挖矿奖励。矿工将广播得到的区块,其他节点会校验这一区块,若无错误,则认为新的区块产生,区块链高度增加。这就是各节点生成新区块保持共识的过程。
将0 gasPrice交易完成需要确认两个问题
下面我们来对0 gasPrice交易相关的操作进行测试。了解零手续费的交易如何产生,如何被txpool接受,打包了零手续费交易的区块能否被认可,确认上述问题的答案。
0x03 零手续费交易测试
a. 单节点测试
首先,我们来确认此交易是否可以进入节点的txpool中,启用一个测试链。默认rpc端口是8545,使用python的web3包发起一笔0 gasPrice转账。
geth --networkid 233 --nodiscover --verbosity 6 --ipcdisable --datadir data0 --rpc --rpcaddr 0.0.0.0 console
节点一发起转账的脚本,转帐前要解锁账户
from web3 import Web3, HTTPProviderweb3 = Web3(HTTPProvider("http://localhost:8545/"))print(web3.eth.accounts)# 转帐前要解锁账户web3.eth.sendTransaction({
"from":web3.eth.accounts[0],
"to":web3.eth.accounts[1],
"value": 10,
"gas":21000,
"gasPrice":0,
})
交互结果
> txpool.content
{
pending: {},
queued: {}
}
> eth.getBalance(eth.accounts[0])
800000000
> personal.unlockAccount(eth.accounts[0],'sissel')
true
> INFO [08-14|11:20:14.972] Submitted transaction fullhash=0x72e81751d2517807cabad24102d3cc2f0f4f2e8b92f1f106f1ee0bf6be734fe4 recipient=0x92636b228148e2824cB8d472Ef2F4e76f2F5059C
> txpool.content
{
pending: {
0x092fda221a114FA702e2f59C217C92cfEB63f5AC: {
3: {
blockHash: "0x0000000000000000000000000000000000000000000000000000000000000000",
blockNumber: null,
from: "0x092fda221a114fa702e2f59c217c92cfeb63f5ac",
gas: "0x5208",
gasPrice: "0x0",
hash: "0x72e81751d2517807cabad24102d3cc2f0f4f2e8b92f1f106f1ee0bf6be734fe4",
input: "0x",
nonce: "0x3",
r: "0x1eca20e3f371ed387b35ca7d3220789399a3f64c449a825e0fa7423b96ce235c",
s: "0x35a58e5cb5027c7903c1f1cc061ae846fb5150186ebbabb2b0766e4cbfc4aee6",
to: "0x92636b228148e2824cb8d472ef2f4e76f2f5059c",
transactionIndex: "0x0",
v: "0x42",
value: "0xa"
}
}
},
queued: {}
}
> miner.start(1)
INFO [08-14|11:20:35.715] Updated mining threads threads=1
INFO [08-14|11:20:35.716] Transaction pool price threshold updated price=18000000000
null
INFO [08-14|11:20:35.717] Starting mining operation
> INFO [08-14|11:20:35.719] Commit new mining work number=115 txs=1 uncles=0 elapsed=223µs
> mINFO [08-14|11:20:36.883] Successfully sealed new block number=115 hash=ce2f34…210039
INFO [08-14|11:20:36.885] ? block reached canonical chain number=110 hash=2b9417…850c25
INFO [08-14|11:20:36.886] ? mined potential block number=115 hash=ce2f34…210039
INFO [08-14|11:20:36.885] Commit new mining work number=116 txs=0 uncles=0 elapsed=202µs
> miner.stop()
true
> eth.getBalance(eth.accounts[0])
799999990
节点一发起的零手续费交易成功,并且挖矿后成功将该交易打包进区块中。
b. 多节点共识测试
现在加入另一个节点
geth --datadir "./" --networkid 233 --rpc --rpcaddr "localhost" --port 30304 --rpcport "8546" --rpcapi "db,eth,net,web3" --verbosity 6 --nodiscover console
使用这些方法添加节点
> admin.nodeInfo
> admin.addPeer()
> admin.peers
节点一仍使用刚才的脚本发起零手续费交易,节点一的txpool中成功添加,但节点二因为gasPrice非法拒绝了此交易。
TRACE[08-15|10:09:24.682] Discarding invalid transaction hash=3902af…49da03 err="transaction underpriced"
> txpool.content
[]
在geth的配置中发现了与此相关的参数
--txpool.pricelimit value Minimum gas price limit to enforce for acceptance into the pool (default: 1)
将其启动时改为0,但节点二的txpool中仍未出现这笔交易。
阅读源码知,此参数确实是控制txpool增加的交易的最低gasPrice,但不能小于1。
if conf.PriceLimit < 1 {
log.Warn("Sanitizing invalid txpool price limit", "provided", conf.PriceLimit, "updated", DefaultTxPoolConfig.PriceLimit)
conf.PriceLimit = DefaultTxPoolConfig.PriceLimit
}
令节点一(txpool中含0 gasPrice)开始挖矿,将该交易打包进区块后,发现节点二认可了此区块,达成共识,两节点高度均增长了。
得到结论:
- 零手续费交易,通常情况下只有发起者的txpool可以接收,其余节点无法通过同步此交易。如若需要,必须进行修改geth源码等操作。
- 虽然这笔交易无法进入其他节点的txpool,但对于含此交易的区块,可以达成共识。
我们将进行简要的源代码分析,支持我们的结论。
0x04 源码分析
(以下的代码分析基于https://github.com/ethereum/go-ethereum的当前最新提交:commit 6d1e292eefa70b5cb76cd03ff61fc6c4550d7c36)
以太坊目前最流行的节点程序(Geth/Parity)都提供了RPC API,用于对接矿池、钱包等其他第三方程序。首先确认一下节点在打包txs时,代码的实现。
i. 交易池
代码路径:./go-ethereum/core/tx_pool.go
// TxPool contains all currently known transactions. Transactions// enter the pool when they are received from the network or submitted// locally. They exit the pool when they are included in the blockchain.type TxPool struct {
config TxPoolConfig
chainconfig *params.ChainConfig
chain blockChain
gasPrice *big.Int //最低的GasPrice限制
/* 其他参数 */}
生成一个tx实例时,发现有对gasPrice的最低要求,具体在这个函数中会拒绝接收此交易。
// validateTx checks whether a transaction is valid according to the consensus// rules and adheres to some heuristic limits of the local node (price and size).func (pool *TxPool) validateTx(tx *types.Transaction, local bool) error {
// 在这里是gasPrice的校验
if !local && pool.gasPrice.Cmp(tx.GasPrice()) > 0 {
return ErrUnderpriced
}
/* ... */
return nil}
ii. 移除低于阈值的交易
代码路径:./go-ethereum/core/tx_list.go 并且在处理txs中,会将低于阈值的交易删除,但本地的交易不会删除。
// Cap finds all the transactions below the given price threshold, drops them// from the priced list and returs them for further removal from the entire pool.func (l *txPricedList) Cap(threshold *big.Int, local *accountSet) types.Transactions {
drop := make(types.Transactions, 0, 128) // Remote underpriced transactions to drop
save := make(types.Transactions, 0, 64) // Local underpriced transactions to keep
for len(*l.items) > 0 {
// Discard stale transactions if found during cleanup
tx := heap.Pop(l.items).(*types.Transaction)
if _, ok := (*l.all)[tx.Hash()]; !ok {
// 如果发现一个已经删除的,那么更新states计数器
l.stales--
continue
}
// Stop the discards if we've reached the threshold
if tx.GasPrice().Cmp(threshold) >= 0 {
// 如果价格不小于阈值, 那么退出
save = append(save, tx)
break
}
// Non stale transaction found, discard unless local
if local.containsTx(tx) { //本地的交易不会删除
save = append(save, tx)
} else {
drop = append(drop, tx)
}
}
for _, tx := range save {
heap.Push(l.items, tx)
}
return drop}
以上部分为区块链网络内一节点,尝试接收或加入 0 gasPrice 的交易时,会有部分过滤或规则限制。但通过修改源码,我们依然可以做到将 0 gasPrice 的交易合法加入到区块中,并进行之后的nonce计算。下面继续源码分析,考察通过此方式得到的区块,是否可以被其他节点接受,达成共识。
iii. 共识校验
代码路径:./go-ethereum/consensus/consensus.go 这是geth中,提供的共识算法engine接口
type Engine interface {
// 签名
Author(header *types.Header) (common.Address, error)
/* 验证了header、seal,处理难度等函数 ... */
// 预处理区块头信息,修改难度等
Prepare(chain ChainReader, header *types.Header) error
// 区块奖励等,挖掘出区块后的事情
Finalize(chain ChainReader, header *types.Header, state *state.StateDB, txs []*types.Transaction,
uncles []*types.Header, receipts []*types.Receipt) (*types.Block, error)
// 计算nonce,若收到更高的链,则退出
Seal(chain ChainReader, block *types.Block, stop <-chan struct{}) (*types.Block, error)
// 计算难度值
CalcDifficulty(chain ChainReader, time uint64, parent *types.Header) *big.Int
// APIs returns the RPC APIs this consensus engine provides.
APIs(chain ChainReader) []rpc.API
// Close terminates any background threads maintained by the consensus engine.
Close() error}
查看VerifySeal(),发现校验了如下内容:
可以看到,其他节点针对共识,检查了签名、nonce等内容,对于其中零手续费的交易没有检验。换句话说,零手续费的交易虽然不能激励矿工,但它依然是合法的。
0x05 利用流程
攻击者首先以偷渡漏洞利用的方式,构造零手续费,正常的transfer交易。待用户解锁账户后,广播交易。具体流程见下图:
0x06 小结
由此我们可以得出,0 gasPrice这样的特殊交易,有如下结论:
- 通常情况下,0 gasPrice可通过节点自身发起加入至txpool中。
- 以 geth 为例,修改geth部分源码重新编译运行,该节点方可接受其他节点发出的特殊交易(目标账户发起的0 gasPrice交易)。此为攻击者需要做的事情。
- 0 gasPrice的交易可以打包进区块,并且符合共识要求。
因为json-rpc接口的攻击方式中,攻击者可以通过偷渡漏洞签名 0 gasPrice交易并广播。通过收集此类0 gasPrice交易并添加至部分矿工的txpool中,当该矿工挖出一个新的区块,这类交易也将会被打包。即攻击者可能与部分矿工联手,或攻击者本身就有一定的运算能力,让矿工不再遵循诚实挖矿维护区块链系统的原则。
0x07 利用价值及防御方案
因为零手续费交易的出现,诸多低收益的攻击都将拥有意义。
提高收益
攻击者可以通过此种方式,结合其他的攻击手法,将被攻击账户中的余额全部转出,达到了收益最大化。
羊毛薅尽
依照《金钱难寐,大盗独行——以太坊 JSON-RPC 接口多种盗币手法大揭秘》中提到的攻击方式,对于账户余额较少,甚至不足以支付转账手续费的情况,可通过上文提到的薅羊毛式攻击方案,将账户中的残羹收入囊中。由于此交易gasPrice为0,可在一区块中同时打包多个此类型交易,例如此合约下的多组交易:0x1a95b271b0535d15fa49932daba31ba612b52946,此区块中的几笔交易:4788940
偷渡代币
在被盗账户已无以太币的情况下,攻击者发现这些账户还存有部分智能合约发行的代币。没有以太币便不能支付gas进行转账,零手续费交易可以完美解决这个问题。直到现在,有诸多无以太币的被攻击账户,仍在被此方式转账代币。
防御方案
由于0 gasPrice交易只是扩展其他攻击方案的手法,还应将防御着眼在之前json-rpc接口利用。
- 对于有被偷渡漏洞攻击的痕迹或可能曾经被偷渡漏洞攻击过的节点,建议将节点上相关账户的资产转移到新的账户后废弃可能被攻击过的账户。
- 建议用户不要使用弱口令作为账户密码,如果已经使用了弱口令,可以根据1.2节末尾的内容解出私钥内容,再次通过 geth account import 命令导入私钥并设置强密码。
- 如节点不需要签名转账等操作,建议节点上不要存在私钥文件。如果需要使用转账操作,务必使用 personal_sendTransaction 接口,而非 personal_unlockAccount 接口。
0x08 影响规模
我们从上面说到的0 gasPrice的交易入手。调查发现,近期依然有许多交易,以0 gasPrice成交。多数0手续费交易都出自矿池:0xb75d1e62b10e4ba91315c4aa3facc536f8a922f5和0x52e44f279f4203dcf680395379e5f9990a69f13c,例如区块6161214、6160889等。
我们注意到,这些0 gasPrice交易,仅有早期的少部分交易,会携带较少的以太币,这符合我们对其薅羊毛特性的预计。经统计,从2017年6月起,陆续有748个账户总计24.2eth被零手续费转账。
在其中也找到了《金钱难寐,大盗独行——以太坊 JSON-RPC 接口多种盗币手法大揭秘》中提到的重放攻击,造成的账户损失:0x682bd7426ab7c7b4b5beed331d5f82e1cf2cecc83c317ccee6b4c4f1ae34d909
被盗走0.05eth
在这些0 gasPrice中,更多的是对合约发行的TOKEN,进行的转账请求,将用户账户中的token转移至合约拥有者账户中,例如:
该账户的tx记录。
攻击者拥有多个矿池的算力,将众多被攻击账户拥有的多种token,转移到相应的账户中,虽然单笔交易金额较小,但可进行此种攻击方式的账户较多,合约较多,且不需要手续费。积少成多,直到现在,攻击者仍在对这些代币进行着拾荒攻击。
0x09 结语
区块链系统基于去中心化能达成交易的共识,一个前提就是,绝大多数的矿工,都会通过诚实挖矿来维持整个比特币系统。当矿工不再诚实,区块链的可信性和去中心化将会大打折扣。当黑客联合矿工,或黑客本身拥有了算力成为矿工,都会在现有攻击手法的基础上,提供更多的扩展攻击方案。0 gasPrice交易的出现,违背了区块链设计初衷,即应对矿工支付手续费作为激励。 区块链技术与虚拟货币的火热,赋予了链上货币们巨大的经济价值,每个人都想在区块链浪潮中分得一杯羹。黑客们更是如此,他们作为盗币者,绞尽脑汁的想着各个角度攻击区块链与合约。当黑客栖身于矿工,他们不但能挖出区块,也能挖出漏洞。