Bug Bounty Report: npm @nibbstack/[email protected] ships coverage-instrumented build artifacts — three shipped mocks exceed the 24,576-byte EIP-170 limit and cannot be deployed; all 18 deployed-contract artifacts carry phantom functions that do not exist in the shipped sources (a failed deploy burns the entire supplied gas limit)

オープン
#304 コメント 1 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
3/5
見積もり時間
1〜2日
初心者へのやさしさ
45/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
javascript, solidity

調査の方向性

Start with the CI order in .github/workflows/node.js.yml, where npm run coverage runs after npm test in the same workspace, and with the coverage script in package.json. Check whether coverage instrumentation leaks into build/ and the packed tarball, using the instrumented-file count and the c_0x grep from the report. Done means a clean build/ and abi/ with no c_0x entries, and the three over-limit mocks compiling under 24,576 bytes, verified by a local check like the report's test/broken-artifacts.test.js.

索引モデルが issue の本文から書いたものです。

説明

Bug Bounty Report — npm 2.6.1 ships coverage-instrumented deployment artifacts: three shipped mocks exceed EIP-170 and can never be deployed (artifacts do not match the shipped sources)

Title: npm @nibbstack/[email protected] ships coverage-instrumented build artifacts — three shipped mocks exceed the 24,576-byte EIP-170 limit and cannot be deployed; all 18 deployed-contract artifacts carry phantom functions that do not exist in the shipped sources (a failed deploy burns the entire supplied gas limit)
CWE: CWE-684 — Incorrect Provision of Specified Functionality (the shipped artifacts do not provide the deployability and the interface that the shipped sources specify)
Affected: npm package @nibbstack/[email protected] (latest) — build/**/*.json and abi/*.json as shipped in the tarball, all compiled from src/contracts/**/*.sol. Undeployable: NFTokenEnumerableMock (25,039 B), NFTokenMetadataEnumerableMock (28,792 B), NFTokenMetadataBaseUriMock (25,548 B) — plus three test mocks (see Details). Instrumented (phantom c_0x… entries): 19 files under build/**; every concrete-contract ABI (18 of 23).
Severity: Medium (claimed conservatively — the program's own impact example "waste 5,000 gas or more in a transaction" is exceeded by over 1,000× (6,000,000 gas consumed on a single failed deploy; up to 30,000,000 when the caller supplies a higher limit), plus the documented deployment path is broken for three of the package's five mock contracts (and three test mocks). Maintainers adjudicate final placement.)


Summary

@nibbstack/[email protected] is the latest release and the only version in scope. The npm tarball ships build artifacts produced by solidity-coverage instead of a clean hardhat compile. Evidence in the tarball:

  1. The instrumented artifacts: 18 deployed-contract artifacts under build/src/**/*.json contain phantom functions named c_0x… (instrumentation stubs, function c_0x…(bytes32) pure {} plus instrumented branch calls) that do not exist in the shipped sources. The shipped ABIs advertise them too — every concrete-contract ABI in the package (18 of 23 files; only the five interface ABIs are clean) carries them, e.g. abi/NFTokenEnumerableMock.json lists c_0x479b80c7, c_0x4b025946, c_0x9f1a7e7d, c_0xaf57d684, c_0xb425ad9a.
  2. The instrumentation roughly doubles the bytecode size, pushing three mock contracts over the EIP-170 runtime code-size limit of 24,576 bytes:
Shipped artifact Shipped runtime Clean compile of the same sources 2.5.1 (previous release) EIP-170 Deployable?
NFTokenEnumerableMock 25,039 B 12,045 B 12,045 B 24,576 NO
NFTokenMetadataEnumerableMock 28,792 B 13,844 B 13,844 B 24,576 NO
NFTokenMetadataBaseUriMock 25,548 B 12,764 B 12,764 B 24,576 NO
NFTokenMetadataMock 24,307 B 12,375 B 12,375 B 24,576 yes (269 B under)
NFTokenMock 19,359 B 9,977 B 9,977 B 24,576 yes

A developer who installs the latest release and deploys these documented mocks gets a transaction that reverts and consumes the entire gas limit supplied. Because eth_estimateGas itself fails for these artifacts, a wallet or script that falls back to a manual gas limit will see that limit burned in full, as measured below.

Root cause (reproduced locally): the project's CI workflow runs npm run coverage after the tests on the same workspace (.github/workflows/node.js.yml: npm run solhint && npm test then npm run coverage). solidity-coverage recompiles the sources into the same build/ directory with instrumentation, and the release tarball was packed from that post-coverage state — the shipped build/build-info/*.json even contains the instrumented sources verbatim. Running npm run coverage on a clean checkout of tag 2.6.1 reproduces the defect deterministically: same instrumented-file count (19), same phantom-function structure, and byte sizes matching within 1 byte (25,039 / 28,792 / 25,549 vs the shipped 25,039 / 28,792 / 25,548). The stub identifiers are not random per run: each one is derived from keccak256("<absolute source path>:<ContractName>") — proven in the provenance section below, where the shipped stubs hash exactly against the maintainer's build-machine path. Release 2.5.1 is clean; this is a 2.6.1 release-process regression, not a protocol choice.

Details

Shipped artifact — excerpt from build/.../NFTokenMock.json (ABI) in the installed package:

{ "inputs": [ { "internalType": "bytes32", "name": "c__0x479b80c7", "type": "bytes32" } ],
  "name": "c_0x479b80c7", "outputs": [], "stateMutability": "pure", "type": "function" }

The corresponding shipped source contains no such function:

$ cd node_modules/@nibbstack/erc721
$ grep -c "c_0x" src/contracts/mocks/nf-token-mock.sol          # shipped source: clean
0
$ grep -rl "c_0x" build/ | wc -l                                # shipped artifacts: instrumented
19
$ grep -c "c_0x" abi/NFTokenEnumerableMock.json                 # shipped ABI: advertises phantoms
5

Consequences of the shipped state:

  • Cannot deploy 3 of the 5 src/contracts/mocks/ contracts (hard EIP-170 violation; see Proof — verified against a live Ethereum mainnet RPC and a local mainnet-rules node). Three of the seven src/tests/mocks/ artifacts are over the limit as well (NFTokenEnumerableTestMock 26,145 B, NFTokenMetadataEnumerableTestMock 29,190 B, NFTokenMetadataTestMock 24,737 B), for six over-limit artifacts in total.
  • Failed deploys burn the entire gas limit (the EVM treats the code-size violation as an exceptional halt; see Proof for measured receipts).
  • ABIs lie: consumers using the documented import (require("@nibbstack/erc721/abi/NFTokenEnumerable.json"), per the README) get interface entries for functions that exist nowhere in the sources.
  • Source verification mismatch: the deployed runtime (if the artifact were deployable) is compiled from instrumented sources; verifying it against the shipped sources on Etherscan fails, because the compiled artifact does not correspond to the sources it ships with.

Impact (program matrix)

  • "Waste 5,000 gas or more in a transaction" — Medium impact in the program's own list — is exceeded by 1,200× on the smallest measured deploy: a single failed deploy consumed 6,000,000 gas (the entire supplied limit), and the entire supplied limit is consumed at every limit tested — 6,000,000 / 8,000,000 / 15,000,000 / 30,000,000 gas (3 artifacts × 4 limits; every receipt status 0x0).
  • Functional loss: the only in-scope release ships artifacts for three documented contracts that provably cannot be deployed on any chain enforcing EIP-170 (Ethereum mainnet included). The docs present the mocks folder as the usage reference.
  • Likelihood: High — affects every consumer of the latest npm release who deploys these artifacts; the condition is unconditional (bytecode size is fixed).
  • Release-integrity regression: previous release 2.5.1 and a clean compile of the same 2.6.1 sources are both fine.

Proof

Environment: npm install @nibbstack/[email protected] from the public registry (tarball shasum 3febedff66327a628e9c7a43f7b2f9473df85f89); sources unmodified. Deployment targets: (a) local node with mainnet rules (Anvil, EIP-170 enforced); (b) read-only simulations against a live Ethereum mainnet RPC (eth.drpc.org) — no transactions sent.

Hardhat test (the requested format)

test/broken-artifacts.test.js — consumes only the installed package; compiles nothing from project sources:

const { expect } = require("chai");
const fs = require("fs");
const path = require("path");
const { ethers } = require("hardhat");

const PKG = path.join(__dirname, "..", "node_modules", "@nibbstack/erc721");
const EIP170 = 24576;

const CASES = [
  // name, artifact file, deploy args
  ["NFTokenMock", "nf-token-mock.sol", []],
  ["NFTokenEnumerableMock", "nf-token-enumerable-mock.sol", []],
  ["NFTokenMetadataMock", "nf-token-metadata-mock.sol", ["N", "S"]],
  ["NFTokenMetadataEnumerableMock", "nf-token-metadata-enumerable-mock.sol", ["N", "S"]],
  ["NFTokenMetadataBaseUriMock", "nf-token-metadata-base-uri-mock.sol", ["N", "S", "https://x/"]],
];

function artifact(name, file) {
  return JSON.parse(
    fs.readFileSync(path.join(PKG, "build/src/contracts/mocks", file, name + ".json"), "utf8")
  );
}

describe("npm 2.6.1 shipped artifacts vs EIP-170 (deployability)", function () {
  it("reports shipped runtime sizes against the 24,576-byte limit", async function () {
    console.log("      contract".padEnd(34) + "runtime size    EIP-170 (24576)");
    for (const [name, file] of CASES) {
      const art = artifact(name, file);
      const size = (art.deployedBytecode.length - 2) / 2;
      console.log(
        `      ${name.padEnd(32)}${String(size).padEnd(16)}${size <= EIP170 ? "ok" : "EXCEEDS -> NOT DEPLOYABLE"}`
      );
    }
  });

  it("CAN deploy the shipped artifacts that are within the limit", async function () {
    for (const [name, file, args] of CASES) {
      const art = artifact(name, file);
      const size = (art.deployedBytecode.length - 2) / 2;
      if (size > EIP170) continue;
      const f = new ethers.ContractFactory(art.abi, art.bytecode, (await ethers.getSigners())[0]);
      const c = await f.deploy(...args);
      await c.deployed();
      expect(c.address).to.match(/^0x[0-9a-fA-F]{40}$/);
    }
  });

  it("CANNOT deploy the three over-limit mocks — reproduced failures", async function () {
    const failures = [];
    for (const [name, file, args] of CASES) {
      const art = artifact(name, file);
      const size = (art.deployedBytecode.length - 2) / 2;
      if (size <= EIP170) continue;
      const f = new ethers.ContractFactory(art.abi, art.bytecode, (await ethers.getSigners())[0]);
      try {
        await f.signer.estimateGas(f.getDeployTransaction(...args));
        failures.push({ name, size, outcome: "UNEXPECTEDLY OK" });
      } catch (e) {
        failures.push({ name, size, outcome: String(e.message).split("\n")[0].slice(0, 120) });
      }
    }
    console.log("      over-limit deploy attempts:");
    for (const f of failures) console.log(`        ${f.name} (${f.size}B): ${f.outcome}`);
    expect(failures.length).to.equal(3);
    for (const f of failures) expect(f.outcome).to.not.equal("UNEXPECTEDLY OK");
  });

  it("shipped ABI advertises phantom functions absent from the shipped sources", async function () {
    const abi = JSON.parse(fs.readFileSync(path.join(PKG, "abi", "NFTokenEnumerableMock.json"), "utf8"));
    const phantoms = abi.filter((x) => (x.name || "").startsWith("c_0x"));
    console.log("      phantom ABI entries:", phantoms.map((x) => x.name).join(", "));
    expect(phantoms.length).to.be.greaterThan(0);

    const src = fs.readFileSync(
      path.join(PKG, "src/contracts/mocks/nf-token-enumerable-mock.sol"),
      "utf8"
    );
    expect(src).to.not.include("c_0x"); // sources are clean; artifacts are not
  });
});
Verbatim transcript (Hardhat)
$ npx hardhat test test/broken-artifacts.test.js

npm 2.6.1 shipped artifacts vs EIP-170 (deployability)
      contract                    runtime size    EIP-170 (24576)
      NFTokenMock                     19359           ok
      NFTokenEnumerableMock           25039           EXCEEDS -> NOT DEPLOYABLE
      NFTokenMetadataMock             24307           ok
      NFTokenMetadataEnumerableMock   28792           EXCEEDS -> NOT DEPLOYABLE
      NFTokenMetadataBaseUriMock      25548           EXCEEDS -> NOT DEPLOYABLE
    ✔ reports shipped runtime sizes against the 24,576-byte limit (43ms)
    ✔ CAN deploy the shipped artifacts that are within the limit (2526ms)
      over-limit deploy attempts:
        NFTokenEnumerableMock (25039B): cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPR
        NFTokenMetadataEnumerableMock (28792B): cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPR
        NFTokenMetadataBaseUriMock (25548B): cannot estimate gas; transaction may fail or may require manual gas limit [ See: https://links.ethers.org/v5-errors-UNPR
    ✔ CANNOT deploy the three over-limit mocks — reproduced failures (78ms)
      phantom ABI entries: c_0x479b80c7, c_0x4b025946, c_0x9f1a7e7d, c_0xaf57d684, c_0xb425ad9a
    ✔ shipped ABI advertises phantom functions absent from the shipped sources


  4 passing (3s)
Verbatim transcript — deployment matrix (local node with mainnet rules, EIP-170 enforced)

Uniform transaction gas limit per attempt: 6,000,000.

node: anvil (EIP-170 enforced, mainnet rules) | uniform deploy gas limit: 6,000,000

===== npm-2.6.1 (shipped) =====
  NFTokenMock                       (runtime=19359B)      | sim:ok                                      | tx: DEPLOYED gasUsed=4296962
  NFTokenEnumerableMock             (runtime=25039B)      | sim:CreateContractSizeLimit                 | tx: REVERTED gasUsed=6000000
  NFTokenMetadataMock               (runtime=24307B)      | sim:ok                                      | tx: DEPLOYED gasUsed=5453917
  NFTokenMetadataEnumerableMock     (runtime=28792B)      | sim:CreateContractSizeLimit                 | tx: REVERTED gasUsed=6000000
  NFTokenMetadataBaseUriMock        (runtime=25548B)      | sim:CreateContractSizeLimit                 | tx: REVERTED gasUsed=6000000

===== npm-2.5.1 (previous release, same node) =====
  NFTokenMock                       (runtime=9977B)       | sim:ok                                      | tx: DEPLOYED gasUsed=2262727
  NFTokenEnumerableMock             (runtime=12045B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2729432
  NFTokenMetadataMock               (runtime=12375B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2862010
  NFTokenMetadataEnumerableMock     (runtime=13844B)      | sim:ok                                      | tx: DEPLOYED gasUsed=3200556
  NFTokenMetadataBaseUriMock        (runtime=12764B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2970304

===== clean compile of tag 2.6.1 (fresh clone), same node =====
  NFTokenMock                       (runtime=9977B)       | sim:ok                                      | tx: DEPLOYED gasUsed=2262727
  NFTokenEnumerableMock             (runtime=12045B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2729432
  NFTokenMetadataMock               (runtime=12375B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2862010
  NFTokenMetadataEnumerableMock     (runtime=13844B)      | sim:ok                                      | tx: DEPLOYED gasUsed=3200556
  NFTokenMetadataBaseUriMock        (runtime=12764B)      | sim:ok                                      | tx: DEPLOYED gasUsed=2970304

The failed transactions are included in a block, reverted with status 0x0, and consume the entire supplied gas limit — verified at limits 6,000,000 / 8,000,000 / 15,000,000 / 30,000,000 gas; each fully consumed (receipts: 3 artifacts × 4 limits).

Ethereum mainnet verification (live RPC, read-only simulation — no tx sent)
Ethereum mainnet via https://eth.drpc.org — READ-ONLY simulations (no transactions sent).
Deploy calldata = artifact bytecode + constructor args, encoded exactly as a deployment
transaction would carry them (ethers getDeployTransaction).
================================================================================================

NFTokenEnumerableMock (runtime 25,039 B):
  eth_call:        error -32000 "max code size exceeded: code size 25039 limit 24576"
  eth_estimateGas: error -32000 "max code size exceeded: code size 25039 limit 24576"

NFTokenMetadataEnumerableMock (runtime 28,792 B):
  eth_call:        error -32000 "max code size exceeded: code size 28792 limit 24576"
  eth_estimateGas: error -32003 "EVM error: CreateContractSizeLimit"

NFTokenMetadataBaseUriMock (runtime 25,548 B):
  eth_call:        error -32000 "max code size exceeded: code size 25548 limit 24576"
  eth_estimateGas: error -32003 "EVM error: CreateContractSizeLimit"

NFTokenMock (control, within limit) (runtime 19,359 B):
  eth_call:        OK (returns the deployed runtime — 19359 bytes of code)
  eth_estimateGas: OK -> 0x421ac8 (4,332,232 gas — deployable)

================================================================================================
Current mainnet rules reject each over-limit creation (EIP-170, 24,576-byte runtime limit).
No state was touched; no transaction was broadcast.
Root-cause reproduction (clean checkout of tag 2.6.1, unmodified sources)
$ npm run coverage        # the same command CI runs
... All files | 80.41 | 68 | 86.49 | 80.98 |
> Istanbul reports written to ./coverage/ and ./coverage.json

$ grep -rl "c_0x" build/ | wc -l
19
$ # sizes after coverage on a fresh clone (compare against the shipped
$ # 25039 / 28792 / 25548):
  NFTokenEnumerableMock:         runtime=25039 B
  NFTokenMetadataEnumerableMock: runtime=28792 B
  NFTokenMetadataBaseUriMock:    runtime=25549 B

Two of the three sizes reproduce exactly (25,039 and 28,792 bytes); the third differs by a single byte (25,549 vs 25,548). The bytecode content is not identical (the stub selectors embed different checkout paths), but the defect class, the sizes, and the phantom ABI structure all reproduce. Stub names necessarily differ per checkout path (they hash the absolute path — see the provenance section below). The broken artifact class, the EIP-170 violations, and the phantom ABI entries reproduce identically.

Instrumentation counts (direct greps): shipped npm 2.6.1 → 19 instrumented files under build/ (18 artifacts + the build-info record); npm 2.5.1 → 0; clean compile → 0.

Stub-identifier provenance (hard evidence that these artifacts came from the maintainer's machine)

Every c_0x… stub name is built as "c_" + keccak256("<absolute path of the source file>:<contract name>").slice(0, 10) (solidity-coverage 0.7.17, injector.js) — i.e. the 0x-prefixed first 10 characters of the hash of an absolute path, on the machine that ran coverage. Hashing the maintainer's build-tree path reproduces the shipped stubs exactly:

keccak256("/Users/xpepermint/Work/nibbstack.com/repos/erc721/src/contracts/mocks/nf-token-enumerable-mock.sol:NFTokenEnumerableMock")
  = 0xb425ad9a6c0bf74f8d8796e3e636ac85b61aa388dc0172c9f440a3f874e8dc46
  -> stub name: c_0xb425ad9a   (matches the value in the shipped NFTokenEnumerableMock artifact/ABI)

keccak256("/Users/xpepermint/Work/nibbstack.com/repos/erc721/src/contracts/ownership/ownable.sol:Ownable")
  = 0x479b80c7da9af1641c56d6f1ed2706e77a818ca4915cc3126232516c88a14fed
  -> stub name: c_0x479b80c7   (matches the value in the shipped NFTokenMock ABI)

keccak256("/Users/xpepermint/Work/nibbstack.com/repos/erc721/src/tests/mocks/nf-token-test-mock.sol:NFTokenTestMock")
  = 0xd25337a1ce3ffa77595e34d6d11b6e8da0c83ea49541a6e0998f01a3d318c1df
  -> stub name: c_0xd25337a1   (matches the value in the shipped NFTokenTestMock artifact)

This also means a local re-run produces different stub names (different path) while producing the same broken artifact class, sizes, and phantom-ABI structure — which is what the reproduction above shows.

Suggested fix (recommended)

  1. Re-publish 2.6.1 artifacts built from a clean state. Never pack from a workspace where coverage has run. Keep npm run coverage in CI, but run it in a way that cannot contaminate the published output (separate job, or restore the workspace before packing).

  2. Make packing immune to workspace state — add a prepack script so any pack/publish always recompiles clean:

    "scripts": {
      "prepack": "rm -rf build abi cache coverage coverage.json && npx hardhat compile"
    }
    
  3. Whitelist the published surface in package.json — consumers need src/ and abi/ only; build/ (14 MB), cache/, coverage/ and coverage.json are development outputs whose accidental inclusion caused this entire class of issue:

    "files": ["src", "abi", "README.md", "LICENSE", "BUG_BOUNTY.md"]
    
  4. Guard in CI: reject a publish if any artifact's deployedBytecode exceeds 24,576 bytes or if any ABI contains c_0x entries.

Scope compliance

  • Scope: only the latest released version is in scope — verified latest = 2.6.1 on the public registry; all testing used that version. The affected artifacts are the compiled form of the package's shipped sources as delivered in the latest release (the npm tarball is how users receive those contracts; the README directs consumers to deploy them).
  • Reporting via GitHub Issues (the program's stated channel; full disclosure is the program's stated preference).
  • All tests local (Anvil + Hardhat network), plus read-only simulations against a public mainnet RPC (no transactions sent, no chain state touched).
  • Prior-art sweep: all 302 pre-existing issues/PRs searched for coverage/instrumented/artifact/size/deploy keywords — the closest is #288 (JSONs not found under build/, resolved in 2.6.1); no report covers the instrumented artifacts, the phantom ABI entries, or the three undeployable mocks.
主要言語
Solidity
スター
1k
フォーク
391
PR マージ指標
30日以内にマージされた PR はありません

環境構築

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

fulldecent/erc721 のほかの issue

fulldecent/erc721 の issue をすべて見る

似ている issue

Build System の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。