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
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 18/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- javascript, nodejs
調査の方向性
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).
- Routing source (backend, uncertified):
- src/backend/main.mo:11059-11069:
getArchives - src/backend/main.mo:22955-22957:
getSeasonRecords(itsarchivesandchainHeadare uncertified too) - src/backend/main.mo:22976-22984:
getSeasonArchives - The backend uses no
CertifiedDataat all (main.mo,lib/andmixins/). The only writer in the tree is the archive, at src/backend/ArchiveCanister.mo:318.
- src/backend/main.mo:11059-11069:
- CLI verifier:
- scripts/verify_ledger.mjs:332-357:
validateArchiveCert, scoped to the id taken from the list - scripts/verify_ledger.mjs:387-431: the routing fetch (season tables at
:394and:403,getArchivesat:431), and a comment asserting that the certificates "prove" these answers - scripts/verify_ledger.mjs:791-792: the active actor, built from the listed id. The per-segment certificate calls are at
:902and:1033, and the active-head certificate at:1079-1097.
- scripts/verify_ledger.mjs:332-357:
- Browser verifier:
- src/frontend/src/ledger.js:127-175:
validateCertifiedHead - src/frontend/src/ledger.js:236-264: the routing fetch
- src/frontend/src/ledger.js:352-369:
certifySegment - src/frontend/src/ledger.js:587-602:
validateArchiveList, which checks shape only - src/frontend/src/main.js:1244-1280:
listArchivespassesgetArchives()through unchanged, andmakeRawArchiveActoraccepts any id
- src/frontend/src/ledger.js:127-175:
- Doc claims that do not hold under substitution:
src/frontend/src/docs.js:1490("Nothing in that pipeline takes the exchange's word for anything"),docs/archive-design.md:564anddocs/pre-mainnet-checklist.md:98("operator-proof" history), anddocs/tasks/done/W2-04-verifier-trust-anchors.md:62-63.
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>/controllersormodule_hash(noread_statein either), or pins previously seen sealed ids. validateArchiveListchecks 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):
- Deploy canister
Canywhere on the IC, exposing the archive'sgetEventsRange/getCertifiedHeadcandid. It serves either a self-consistent tape from seq 0 or one continuing from the last genuine sealed head, and callsCertifiedData.set(head). - 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, answergetSeasonRecordsandgetSeasonArchives(N)consistently. 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
--foldProof-of-Reserves liabilities (foreign-suffixdrops all 7.5 ICP of the honest run's liabilities and still passes); - the hash-chained
#configauthority record; - the
--season Nprize-audit chain anchored toSeasonRecord.chainHead.
- the
- 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:
_blackholeAtSealdefaults tofalse(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-liarabove shows the #55.1 fix holding;foreign-canistershows it bypassed. - H-18 / F21:
validateArchiveListchecks 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-headscenario) is the case a cid-scoped lookup correctly refuses. This is the reverse: a genuine certificate for a canister the attacker chose.
Suggested fix
- Certify the routing table on the backend. Keep a 32-byte digest over the ordered
getArchivesrows, every_seasonArchivesrow, and eachSeasonRecord's(season, archives, chainHead, finalEventSeq). Set it withCertifiedData.setfrom a singlerecertifyRouting()helper, the archive's own pattern (ArchiveCanister.mo:318;getCertifiedHeadat:333-345). Call it after every mutation of_archivesSealed,archive0,_activeFirstSeq,_seasonArchivesorseasonRecords(for examplemain.mo:10758,10873-10874,11854-11861,11919,12227-12232,22851-22905,23156) and inpostupgrade(main.mo:9467). Serve the rows together withCertifiedData.getCertificate(). - In both verifiers, recompute the digest from the served rows. Validate it with the existing
validateArchiveCert/validateCertifiedHeadlogic against the backend id given out of band (--backend; the build-timecanisterEnvid in the browser) before trusting any listed id. - Production, optional: where blackhole-at-seal is claimed, also read each sealed segment's
module_hash(pinned to the archive wasm) andcontrollers(must be empty) through a certifiedread_state. A controllers check alone is not enough, because anyone can add the backend as a controller of their own canister. - Correct
verify_ledger.mjs:387-390and the W2-04 claim. Addforeign-canister/foreign-suffixtotests/fixtures/verify-ledger-stub, so thattests/test_verify_ledger_gate.shpins 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 を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
dfinity/public-multidex のほかの issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 35/100
dfinity/public-multidex#96 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 45/100
dfinity/public-multidex#95 ·
-
難易度 3/5 1〜2日 初心者へのやさしさ 35/100
dfinity/public-multidex#94 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 15/100
dfinity/public-multidex#93 ·
-
難易度 4/5 3〜5日 初心者へのやさしさ 20/100
dfinity/public-multidex#92 ·
dfinity/public-multidex の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
メンテナーはふだん 1 日以内に返信
-
S3: friction & debt
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
MedakaLang/medaka#5010 ·
メンテナーはふだん 1 日以内に返信
-
[BUG] apply-crs.sh reads wrong config.ini key ('namespace' instead of 'PH_NAMESPACE') and uses non-portable mktemp suffix対応中かも @muskan-creates352 が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
openMF/mifos-gazelle#214 ·
-
status: ready
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
melodic-software/claude-code-plugins#14389 ·
メンテナーはふだん 1 日以内に返信
-
area:ci good first issue priority:p2 type:test
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信