Security Research

Sky-High Fee Analysis: ethjs-util Floating-Point Data Processing Flaw

#Web3 Security#Vulnerability Analysis
On a near-black deep green-blue background, a broken code chain centers on a floating-point number triggering a copper-orange fissure, with hexadecimal and binary data scattered around and a transaction receipt showing an abnormally high gas fee value — symbolizing the ethjs-util floating-point processing flaw that led to a sky-high transaction fee

Incident Background

This analysis stems from a transaction that transferred 100,000 USDT but incurred a sky-high fee of 7,676 ETH.

Etherscan transaction record

Transaction hash: 0x2c9931793876db33b1a9aad123ad4921dfb9cd5e59dbb78ce78f277759587115

Key Code Analysis

The analysis began based on the description in this Issue.

We will explain the problem in reverse order, which makes it easier to understand. The core issue is that ethjs-util’s intToBuffer does not support passing in floating-point data.

Let’s first look at the key code. Most of the discussion centers on ethereumjs, focusing primarily on the values of the two parameters maxPriorityFeePerGas and maxFeePerGas. Because floating-point values were passed in, the calculations went wrong, producing an incorrect fee and thereby triggering the “sky-high fee incident.”

After analysis, both of these parameters are processed by toBuffer, so we began analyzing toBuffer.

Reference code: ethereumjs-monorepo eip1559Transaction.ts#L200-L201

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

toBuffer calls ethjs-util’s intToBuffer function, which mainly handles two things.

Reference code: ethjs-util.js#L1950

function intToBuffer(i) {
  var hex = intToHex(i);
  return new Buffer(padToEven(hex.slice(2)), 'hex');
}
  1. Convert int to Hex
function intToHex(i) {
  var hex = i.toString(16);
  return '0x' + hex;
}
  1. Check whether the length is evenly divisible by 2; if not, prepend a 0 to the string. This is mainly to ensure the data can be successfully written into the buffer in pairs of two characters.
function padToEven(value) {
  var a = value;
  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;
}

Using the erroneous sample data 33974229950.550003 for analysis, after processing through intToHex and padToEven within the intToBuffer function, the result is 7e9059bbe.8ccd. Both browser JS and Node.js produce the same result at this stage.

intToBuffer processing

The divergence occurs in the new Buffer operation: new Buffer(padToEven(hex.slice(2)), 'hex');

Processing Analysis: Browser JS

The JS files were bundled with webpack, referenced, and then debugged in the browser.

First, the input sample string 33974229950.550003 enters the intToBuffer function for processing.

intToBuffer processing

Analyzing the processing flow of intToBuffer in parallel, this part follows the same code logic as in the “Key Code Analysis” section. The conversion yields 7e9059bbe.8ccd.

Next, we analyze how the converted string is filled into the buffer. Through this step we obtain the buffer contents 126, 144, 89, 187, 14, 140, 205, corresponding to 7e, 90, 59, bb, e, 8c, cd.

buffer contents

Here we noticed that the decimal point in e. disappeared. So we began investigating the mystery of the vanishing decimal point, tracing it to the hexWrite function. This function splits the resulting data into groups of two characters and then uses parseInt to parse each split group.

However, parseInt('e.',16) -> 14 === parseInt('e',16) -> 14. The vanished decimal point was swallowed by parseInt, causing the data ultimately written to the buffer to be incorrect. The value written to the buffer is 7e9059bbe8ccd.

browser parse result

Processing Analysis: Node.js

The problem in the browser was that the decimal point in 7e9059bbe.8ccd was swallowed by parseInt when writing to the buffer, causing the data to be incorrect. However, upon analysis, Node.js’s data is also wrong, and the cause of the error is different from the browser’s.

Let’s first look at the following example:

Three different sets of data fed into a buffer in Node.js produced the same result. After analysis, it turns out Node.js’s buffer has a subtle behavior: when data is split into pairs, if a pair cannot be properly parsed as hex, that pair and all subsequent data are left unprocessed, and only the portion that could be properly parsed is returned. This can be thought of as truncation. This behavior can be traced to the code logic in node_buffer.cc within Node.js’s underlying buffer implementation.

> new Buffer('7e9059bbe', 'hex')
<Buffer 7e 90 59 bb>
> new Buffer('7e9059bbe.8ccd', 'hex')
<Buffer 7e 90 59 bb>
> new Buffer('7e9059bb', 'hex')
<Buffer 7e 90 59 bb>

Node.js execution result

Comparison of Execution Results

Because Node.js truncates e. and everything after it from the original data 7e9059bbe.8ccd, the final incorrect value is 7e9059bb, which is smaller than the correct value 07e9059bbe.

Browser execution result:

browser execution result

Because the browser swallows the . from the original data 7e9059bbe.8ccd, the final incorrect value is 7e9059bbe8ccd, which is much larger than the correct value 07e9059bbe.

Root Cause

ethjs-util’s intToBuffer function does not support floating-point data, and within this function there is no check on the type of the input variable to ensure it is of the expected type. Since ethereumjs’s toBuffer invokes ethjs-util’s intToBuffer for processing and also does not validate the data, this incident occurred. Fortunately, a kind miner ultimately returned the “sky-high fee of 7,626 ETH.”

Lessons Learned

From the perspective of third-party library authors, reliable and secure coding standards should be followed during development. At the beginning of each function, the legitimacy of input data should be checked to ensure the data and code logic execute as expected.

From the perspective of library consumers, users should read the third-party library’s development and integration documentation themselves, and also test the logic that interfaces with the third-party library. By constructing large volumes of test data, they can ensure the business executes as expected under normal conditions and maintain a high standard of test case coverage.