Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

天价交易费的分析与思考-如何复现一笔交易费为7676ETH的交易 #38

Open
aaronisme opened this issue Oct 1, 2021 · 4 comments

Comments

@aaronisme
Copy link
Owner

aaronisme commented Oct 1, 2021

天价交易费的分析与思考-如何复现一笔交易费为7676ETH的交易

2021年9月27号一笔7676ETH的交易的出现引爆了整个加密货币社区.到底是什么原因出现的这一笔交易引起了诸多猜测。2天后也是9月29号交易的发出者Deversifi在其网站上披露了更多细节,以及事故分析。

作为一名老前端开发者,区块链安全研究者,作者第一时间阅读了这片文章。下边带大家一探究竟尝试解释一下这笔交易的由来。

背景

Deversifi在不久之前将交易类型升级到EIP-1559的交易,并且它的前端支持连接Metamask和Ledger两种钱包。然而这两种钱包交易构建的方法是非常不同,对于Metamask来说,交易构建是Metamask来负责的,Dapp开发者基本不用操作什么。而对于ledger来说,Dapp需要自己构建交易,构建完成后传给Ledger进行交易的签名。目前Dapp开发者基本都会使用"@ethereumjs/tx" 来构建交易。下边就是官方文档给出的构建交易的示例代码。

import Common, { Chain, Hardfork } from '@ethereumjs/common'
import { FeeMarketEIP1559Transaction } from '@ethereumjs/tx'

const common = new Common({ chain: Chain.Mainnet, hardfork: Hardfork.London })

const txData = {
  "data": "0x1a8451e600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
  "gasLimit": "0x02625a00",
  "maxPriorityFeePerGas": "0x01",
  "maxFeePerGas": "0xff",
  "nonce": "0x00",
  "to": "0xcccccccccccccccccccccccccccccccccccccccc",
  "value": "0x0186a0",
  "v": "0x01",
  "r": "0xafb6e247b1c490e284053c87ab5f6b59e219d51f743f7a4d83e400782bc7e4b9",
  "s": "0x479a268e0e0acd4de3f1e28e4fac2a6b32a4195e8dfa9d19147abe8807aa6f64",
  "chainId": "0x01",
  "accessList": [],
  "type": "0x02"
}

const tx = FeeMarketEIP1559Transaction.fromTxData(txData, { common })

一探究竟

从例子上看似乎没什么问题,只要将txData的参数传对,应该也不会出现什么问题。那到底这个天价交易是如何出现的?我们来通过代码一探究竟。

public constructor(txData: FeeMarketEIP1559TxData, opts: TxOptions = {}) {
    ...

    this.maxFeePerGas = new BN(toBuffer(maxFeePerGas === '' ? '0x' : maxFeePerGas))
    this.maxPriorityFeePerGas = new BN(
      toBuffer(maxPriorityFeePerGas === '' ? '0x' : maxPriorityFeePerGas)
    )

上边是FeeMarketEIP1559Transaction的constructor函数,我们注意maxPriorityFeePerGas和maxFeePerGas都是将传入的字符串转成Buffer对象,然后创建了一个BN的对象用于后续计算。从文档中看@ethereumjs/tx的开发者应该是期望上述两个参数都应是string类型,并且源码中的类型声明也是这么定义的 https://github.com/ethereumjs/ethereumjs-monorepo/blob/b0477d64c259b354ff57bab7e77be43081216fea/packages/tx/src/types.ts#L263:3

那么如果传入的类型如果不是string那么会发生什么?如果传入的是一个浮点数会发生什么?从Deversifi的事故分析中我们应该也能看到,他们应该传入的并不是string,而是浮点数。要回答这个问题我们就得进入toBuffer方法中看看了。

toBuffer方法是定义在ethereumjs-util中的下边是其代码片段。

// ethereumjs-util
import { intToBuffer } from 'ethjs-util'
export const toBuffer = function (v: ToBufferInputTypes): Buffer {
  ...
  if (typeof v === 'number') {
    return intToBuffer(v)
  }
}

// ethjs-util
// https://github.com/ethjs/ethjs-util/blob/e9aede668177b6d1ea62d741ba1c19402bc337b3/src/index.js#L39
/**
 * Converts a `Number` into a hex `String`
 * @param {Number} i
 * @return {String}
 */

function padToEven(value) {
  var a = value; // eslint-disable-line

  if (typeof a !== 'string') {
    throw new Error(`[ethjs-util] while padding to even, value must be string, is currently ${typeof a}, while padToEven.`);
  }

  if (a.length % 2) {
    a = `0${a}`;
  }

  return a;
}

function intToHex(i) {
  var hex = i.toString(16); // eslint-disable-line

  return `0x${hex}`;
}

/**
 * Converts an `Number` to a `Buffer`
 * @param {Number} i
 * @return {Buffer}
 */
function intToBuffer(i) {
  const hex = intToHex(i);

  return new Buffer(padToEven(hex.slice(2)), 'hex');
}

从代码中可以发现,如果传入的是一个浮点数,那么它会被先按十六进制转成字符串,补0使得字符串长度为偶数后用来生成对应的Buffer。例如1.53125在toString(16)就会变为'1.88',到这一步为止一个浮点数被变为字符串。

var a = 1.53125
a.toString(16) 
'1.88'

下一步才是真正的问题所在。

问题根本

下一步就是这个Buffer如何生成的了。因为Buffer是Node.js中数据类型,所以在浏览器中一般会引入相应的polyfill,用的最多的是feross/buffer, ethjs-util正是使用的是它。

// feross/buffer
// https://github.com/feross/buffer/blob/master/index.js#L828:10
.. 
function hexWrite (buf, string, offset, length) {
  offset = Number(offset) || 0
  const remaining = buf.length - offset
  if (!length) {
    length = remaining
  } else {
    length = Number(length)
    if (length > remaining) {
      length = remaining
    }
  }

  const strLen = string.length

  if (length > strLen / 2) {
    length = strLen / 2
  }
  let i
  for (i = 0; i < length; ++i) {
    const parsed = parseInt(string.substr(i * 2, 2), 16)
    if (numberIsNaN(parsed)) return i
    buf[offset + i] = parsed
  }
  return i
}

对于encoding为'hex'的字符串,feross/buffer对调用上述的hexWrite来生成Buffer。关键点来了,这个函数是按两个字符为间隔来调用parseInt方法来进行转换。例如'1.88'生成的Buffer是[1,136], '01.8'生成的Buffer则是[1], 为什么是这样呢?

因为经过按两位分割后,"1."会被转换为1 ".8"则会返回NaN导致函数退出。MDN文档中其实已经描述的非常清楚。

If parseInt encounters a character that is not a numeral in the specified radix, it ignores it and all succeeding characters and returns the integer value parsed up to that point. parseInt truncates numbers to integer values. Leading and trailing spaces are allowed.

好了,至此1.53125这个浮点数就变成了Buffer[1,136] 转化为整数为392。但是如果是1.5的话则为Buffer[1]也就1。

1.53125 => intToHex => '1.88' => new Buffer(padToEven(hex.slice(2)), 'hex') => Buffer[1,136] // 392
1.5 => intToHex => '1.8' => new Buffer(padToEven(hex.slice(2)), 'hex') => Buffer[1] // 1

这样1.53125就摇身一变成为了392,这就天价交易费的由来,一个浮点数的巨变!

到底想用多少交易费?

回到那笔天价的交易来说来说,到底最初设定了多少手续费呢?最后我们尝试推测一下。从链上取得交易的数据,我们发现maxPriorityFeePerGas的值为bd28c8360cb333, 我们已经知道了这是一个错误的浮点数巨变后的值,根据上边的分析原理,小数点后的值可能为"0cb333"。 整数部分大概为bd28c836,转换为整数为3173566518,3Gwei左右感觉相对合理。

最后我猜测一下,最初设定的值可能为3173566518.0496095,放大后的16进制数为bd28c8360cb338(由于JavaScript的number精度问题稍有偏差)详细代码可以参考:https://github.com/aaronisme/7676ETH-fee-tx

@freeyao
Copy link

freeyao commented Oct 3, 2021

有三个延展问题:

  1. 看起来是ethJS的代码和前端传参共同导致了这个问题,这里的责任你认为应该怎么划分?其它使用了ethJS代码的项目,是否也会出现这个问题呢?
  2. 这个bug应该也影响了 DeversiFi 的其它用户。当有decimal的时候,最终的结果和传参预期可能不一致,一种可能性是导致错误(没有那么多eth),另一种可能是构建了错误费用的交易(但是没有那么高,因此没有被注意),这个是否能从链上分析出来?从软件工程角度,有什么办法可以提早发现这个bug?
  3. 为什么gasPrice参数也是string,就没有出现过这样的问题?是因为前端传参不会传decimal,还是有其它的检查验证?

TYPO:
纰漏 -> 披露
不久之前{讲}交易类型 -> 将
事例代码 -> 示例代码
响应 -> 相应

@aaronisme
Copy link
Owner Author

aaronisme commented Oct 4, 2021

有三个延展问题:

  1. 看起来是ethJS的代码和前端传参共同导致了这个问题,这里的责任你认为应该怎么划分?其它使用了ethJS代码的项目,是否也会出现这个问题呢?

从我的角度看更多是Dapp的开发者的责任,没有充分的了解代码。当然ethjs的开发者也存在类型判断不充分的情况,作为基础类库应该考虑到调用者可能的各种情况。所以从现代前端开发的角度,更应该使用Typescript,而非Javascript。

  1. 这个bug应该也影响了 DeversiFi 的其它用户。当有decimal的时候,最终的结果和传参预期可能不一致,一种可能性是导致错误(没有那么多eth),另一种可能是构建了错误费用的交易(但是没有那么高,因此没有被注意),这个是否能从链上分析出来?从软件工程角度,有什么办法可以提早发现这个bug?

这个错误要出现是非常偶然的,一方面需要用户的walle里有非常多的eth,另外一方面,如果parseInt的时候遇到是“.x”那么数据就会截断,不会出现这样的情况.(1.5的那个例子)

  1. 为什么gasPrice参数也是string,就没有出现过这样的问题?是因为前端传参不会传decimal,还是有其它的检查验证?

这是个好问题,一般的来说Dapp开发者都会将decimal 转为int,然后再转为对应的16进制hex,传给ethjs。从代码上并没有类型checking,有可能是如果是过大的fee, signer可以正常展示出来。

TYPO: 纰漏 -> 披露 不久之前{讲}交易类型 -> 将 事例代码 -> 示例代码 响应 -> 相应

多谢质证,已更新。

@freeyao
Copy link

freeyao commented Oct 11, 2021

谁之过

巧合的碎片

这个「天价BUG」是多块碎片的巧合,以下碎片缺一不可:

  1. 前端直接使用Ledger,因此需要应用独立构建交易
  2. ethereumjs没有类型检查(JS特性)
  3. 参数传入了浮点数
  4. 该浮点数具备一定特征,即转为16进制后长度为奇数(含小数点)
  5. 用户在Ledger确认交易时忽略了异常显示
  6. 用户的账户里有足够多的Ether支付手续费

潜在肇事者

责任究竟在谁?我们列出潜在肇事者,看看他们做错了什么。

  1. ethereumjs
  2. Deversifi
  3. Bitfinex
  4. Ledger

ethereumjs

  • 本可以对参数类型进行检查,抛出错误;
  • 或使用TypeScript这种强类型的语言。

Deversifi

  • 本可以在前端做好类型检查,构建交易时可以拒绝浮点数或将其转成整数。
  • 本可以设置手续费检查,对过高的手续费给予提示。
  • 本可以做好错误分析,提前发现此问题(其它用户也遇到了此问题,但因eth余额不足而无法发送交易)。

Bitfinex

  • 本可以仔细检查Ledger上的参数,对异常进行二次确认。
  • 本可以设置手续费上限,或使用冷钱包和温钱包策略,减少大额账户的操作频率。
  • 本可以没有那么多钱。

Ledger

  • 本可以早点修复BUG。

世界上没有后悔药,划清责任并非要指责谁,而是帮助我们不再犯错。从对自己负责的角度,Bitfinex安全制度松懈,没有把好最后一关,即使没有这样的BUG出现,安全上的松懈也存在被黑客利用的风险;其次是Deversifi,没有处理好浮点数,直接导致了这次事故;ethereumjs和Ledger的问题相对较小,但他们本可以做得更好。
事情到了这里,本应该告一段落。可我心头有个疑惑一直没有解开,为什么在EIP-1559应用之前没有发生类似的事情呢?

EIP-1559费用机制对参数的改变

在EIP-1559应用之前,以太坊的交易手续费只需要为单位gas设置单价,即gasprice即可。在构建交易时,钱包或应用调用第三方接口获取预测值或自行进行预测,有时也允许用户自定义调整。一般来说,会设置为整数
的gwei,例如50gwei,实际上传输的参数是50 * 10^9 =50,000,000,000,即使用户指定小数,只要其最低位大于 10^(-9), 都不会出现浮点数。
而在EIP-1559应用之后,问题变得复杂了。每个块的basefee需要根据前一个块的basefee、gas使用量、gas目标值来进行计算,因此会出现大整数的计算,包括除法,具体公式如下:

使用量大于目标值

gas_used_delta = parent_gas_used - parent_gas_target base_fee_per_gas_delta = max(parent_base_fee_per_gas * gas_used_delta // parent_gas_target // BASE_FEE_MAX_CHANGE_DENOMINATOR, 1) expected_base_fee_per_gas = parent_base_fee_per_gas + base_fee_per_gas_delta

使用量小于目标值

gas_used_delta = parent_gas_target - parent_gas_used base_fee_per_gas_delta = parent_base_fee_per_gas * gas_used_delta // parent_gas_target // BASE_FEE_MAX_CHANGE_DENOMINATOR expected_base_fee_per_gas = parent_base_fee_per_gas - base_fee_per_gas_delta

关于这一点,MyEtherWallet的创始人Taylor Monahan早就发出了吐槽,她认为引入这样的大整数对于前端应用非常危险,因为大整数的计算容易出现精度问题,并且会带来「浮点数」,不幸的是,这一次就发生了。Deversifi在复盘报告中写道:「我们使用了不同的费用预测供应商提供的数据来预测手续费报价参数」。

最后一块碎片

至此,我们找到了此次事件原因的最后一块碎片——EIP-1559以及另一个潜在肇事者——以太坊核心开发者(Core Devs)。
EIP-1559的上线是充满政治性的,没有经过足够完备的测试,对待安全问题极其随意,也没有让生态伙伴有足够的时间集成,有些急躁。

视安全为儿戏

5月29日,以太坊的安全研究员Martin Holst Swende 发布了 EIP-1559的安全漏洞——对maxFeePerGas 参数大小未做限制可能导致的对网络的DDOS攻击。
核心开发者会议决定修改EIP-1559的协议共识,增加对参数大小的限制,没有修改伦敦升级的时间表。
6月24日,伦敦升级在Ropsten测试网开始测试。
7月21日,测试网Ropsten出现BUG,OpenEthereum客户端停止同步。起先被认为是OpenEthereum的问题,最后发现并非如此。原因在于此前的共识修改后,Geth客户端、Erigon、Nethermind并没有相应地修改代码。一笔交易意外地触发了这个共识修改的边界,导致没有修改共识的客户端与修改共识的客户端(OpenEthereum、Besu)分叉,但由于Ropsten上的所有算力都集中在Geth客户端,因此OpenEthereum和Besu无法出块,只能停机。
此时,距离伦敦升级只有15天时间。以太坊网络采用率超过80%的客户端Geth客户端紧急发布版本,此前已经为伦敦升级准备过一次升级的全节点必须再次升级。
时间回到4月2日,以太坊核心开发者会议协调人Tim Beiko在回答社区成员关于EIP-1559的安全审计的问题时说道:「并非所有提案都需要技术审计,1559的最大影响在经济上」。
这种盲目的自信是测试网运行一个月后,伦敦升级前15天才发现开发团队压根不知道共识变更的原因吗?

对生态不负责任

EIP-1559的升级对生态造成了广泛的影响, 包括钱包(尤其是硬件钱包)、基础库(例如ethereumjs)、基础服务(GasNow、Etherscan)都需要对此进行应对。每个团队都花了大量的精力调研如何对此进行反应,一些团队选择隐藏这些细节,不给用户增加困扰(例如GasNow、imToken),另外一些团队缺少资源来开发,例如Trezor表示既然传统的交易仍然可以发送,那么用户浪费一点手续费就浪费吧,他们缺少开发资源。在Tim Beiko慷慨地给出了赏金要约后,这个问题快速得到解决。
对整个生态来说,这造成了巨大的人力物力开销,此外,如果不升级,使用传统交易类型就会导致用户多付手续费,这让用户也会逼迫产品更新。因此,看起来是向前兼容,但实际上充满了歧视。此外,这真的像核心开发者声称的那样改善了用户体验吗?
DeversiFi的复盘报告里的一句话可以给出部分答案——「EIP-1559并不能防止意外的巨额手续费支出。」
好在这些意外支出给到了具备归还能力的矿工,而非烧掉,否则我们只能指望「链上保险」或是「可以平复一切的时间」。

@aaronisme
Copy link
Owner Author

aaronisme commented Oct 11, 2021

ethereumjs

  • 本可以对参数类型进行检查,抛出错误;
  • 或使用TypeScript这种强类型的语言

ethereumjs 是使用Typescript开发的,我猜测是Deversifi没有使用Typescript开发

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants